← back to Fleet Lifecycle Steward
Fleet Lifecycle action tier — schedule-aware safe restart + gated memos
0341b79f64c268f1fc879f7b80f0f6f84ced27f7 · 2026-06-24 16:41:52 -0700 · Steve Abrams
DTD-decided run model: scheduled launchd (Q1), subagent+script no daemon (Q2),
14d staleness (Q3), default-deny restricted restart (Q4). Reads the registry
manifest the detect-tier keeps fresh; never edits that repo. Drafts gated memos
to pending-approval; never restarts customer-facing/mid-write services.
Files touched
A .gitignoreA README.mdA fleet-act.mjsA safe-restart-allowlist.json
Diff
commit 0341b79f64c268f1fc879f7b80f0f6f84ced27f7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jun 24 16:41:52 2026 -0700
Fleet Lifecycle action tier — schedule-aware safe restart + gated memos
DTD-decided run model: scheduled launchd (Q1), subagent+script no daemon (Q2),
14d staleness (Q3), default-deny restricted restart (Q4). Reads the registry
manifest the detect-tier keeps fresh; never edits that repo. Drafts gated memos
to pending-approval; never restarts customer-facing/mid-write services.
---
.gitignore | 11 +++
README.md | 34 +++++++++
fleet-act.mjs | 167 ++++++++++++++++++++++++++++++++++++++++++++
safe-restart-allowlist.json | 9 +++
4 files changed, 221 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cb92648
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+runs.jsonl
+reconcile.out.log
+reconcile.err.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ea161d3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# fleet-lifecycle-steward — the ACTION tier
+
+The Fleet Lifecycle Officer's mechanical action tier. Pairs with the Claude
+subagent `fleet-lifecycle-steward` (judgment tier) and the registry's own
+detection tier. Steve's ask: **DW agents never go to sleep unless old & irrelevant.**
+
+## Two-tier fleet reconcile (no overlap, no collision)
+
+| Tier | Where | Job | Plist |
+|---|---|---|---|
+| **Detect + alert** (read-only) | `~/Projects/_shared/fleet-registry/scripts/run.sh` | refresh manifest → DARK/ZOMBIE/WARN findings → CNCP card + George email. *Never restarts/kills.* | `com.steve.fleet-reconcile` |
+| **Act + draft** (this repo) | `fleet-act.mjs` | safe auto-restart (allowlist) + gated memo drafts to `pending-approval` + 14d retirement flags | `com.steve.fleet-act` |
+| **Judgment** | Claude subagent `fleet-lifecycle-steward` | ambiguous outages, retirement decisions, anything needing reasoning | (spawned) |
+
+The detect tier owns the registry repo; this tier only **reads** the manifest it
+keeps fresh. Never edit the other repo's files or its plist.
+
+## Decisions baked in (DTD 2026-06-24)
+
+- **Q1=A scheduled** (launchd `StartInterval` 900s, "loaded" not a daemon)
+- **Q2=A subagent + script**, no new always-on daemon
+- **Q3=A 14-day** staleness window (`STALE_DAYS` in fleet-act.mjs; bump to 30 if noisy)
+- **Q4=B restricted restart** — DEFAULT-DENY allowlist (`safe-restart-allowlist.json`);
+ customer-facing / mid-write / Kamatera services are GATED (memo, never auto-bounced)
+
+## Run
+
+```sh
+node fleet-act.mjs # dry-run — prints findings, touches nothing
+node fleet-act.mjs --apply # safe restarts + writes gated memos (idempotent)
+```
+
+Memos land in `~/.claude/yolo-queue/pending-approval/fleet-*.md` — Steve approves.
+Nothing customer-facing is ever restarted or retired without sign-off.
diff --git a/fleet-act.mjs b/fleet-act.mjs
new file mode 100644
index 0000000..b272362
--- /dev/null
+++ b/fleet-act.mjs
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+// fleet-reconcile.mjs — the cheap mechanical tier of the Fleet Lifecycle Officer.
+// DTD-decided 2026-06-24: scheduled launchd SCRIPT (not a daemon) that diffs the
+// service registry manifest, auto-restarts ONLY the safe allowlisted set (Q4=B),
+// and drafts GATED memos for everything risky/stale. Escalates judgment to the
+// `fleet-lifecycle-steward` Claude subagent. Consumes the registry, never forks it.
+//
+// Modes: (default) dry-run — print only, touch nothing.
+// --apply — perform safe restarts + write gated memo drafts (idempotent).
+// Exit 0 always (a scheduled job that hard-fails is itself a silent death).
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+const HOME = homedir();
+const MANIFEST = process.env.FLEET_MANIFEST || join(HOME, "Projects/_shared/fleet-registry/manifest.json");
+const ALLOWLIST = join(HOME, "Projects/fleet-lifecycle-steward/safe-restart-allowlist.json");
+const PENDING = join(HOME, ".claude/yolo-queue/pending-approval");
+const RUNLOG = join(HOME, "Projects/fleet-lifecycle-steward/runs.jsonl");
+const APPLY = process.argv.includes("--apply");
+const STALE_DAYS = 14; // Q3 = A (14d). Bump to 30 here if the queue gets noisy.
+
+// --- load ---
+const manifest = JSON.parse(readFileSync(MANIFEST, "utf8"));
+const all = Array.isArray(manifest.entries) ? manifest.entries : [];
+// real services only — drop the _README sentinel + anything without a real machine/kind
+const REAL_MACHINES = new Set(["mac2", "kamatera", "mac1"]);
+const entries = all.filter(e => e && REAL_MACHINES.has(e.machine) && e.kind && !String(e.id || "").startsWith("_"));
+
+const allow = existsSync(ALLOWLIST) ? JSON.parse(readFileSync(ALLOWLIST, "utf8")) : { allow: [], deny_patterns: [] };
+const allowSet = new Set(allow.allow || []);
+const denyRe = (allow.deny_patterns || []).map(p => new RegExp(p, "i"));
+
+// --- liveness (schedule-aware) ---
+const isScheduled = e => !!e.schedule || e.kind === "launchd" || e.kind === "plist-only";
+const HEALTHY_SCHED = new Set(["loaded", "running", "online"]);
+function snapshotHealthy(e) {
+ return isScheduled(e) ? HEALTHY_SCHED.has(e.live_status) : e.live_status === "online";
+}
+
+// optional real-liveness poll for daemons that CLAIM online but expose a health_url
+async function trulyAlive(e) {
+ if (!snapshotHealthy(e)) return false;
+ if (!e.health_url) return true; // worker/job: snapshot is all we have
+ try {
+ const ac = new AbortController();
+ const t = setTimeout(() => ac.abort(), 3000);
+ const r = await fetch(e.health_url, { signal: ac.signal, redirect: "manual" });
+ clearTimeout(t);
+ return r.status >= 200 && r.status < 400; // 200 with a dead worker behind it = NOT alive
+ } catch { return false; }
+}
+
+// --- classification ---
+const daysSince = iso => iso ? (Date.now() - new Date(iso).getTime()) / 86400000 : Infinity;
+function restartSafe(e) {
+ if (e.machine !== "mac2") return false; // kamatera needs ssh → gate in v1
+ if (denyRe.some(re => re.test(e.id) || re.test(e.name || ""))) return false; // customer-facing / mid-write
+ return allowSet.has(e.id); // DEFAULT-DENY: only explicitly-trusted ids auto-restart
+}
+function restartCmd(e) {
+ if (e.kind === "pm2") return `pm2 restart ${JSON.stringify(e.name)}`;
+ if (e.kind === "launchd" || e.kind === "plist-only")
+ return `launchctl kickstart -k gui/$(id -u)/${e.name}`;
+ return null;
+}
+
+// --- gated memo drafting (idempotent by stable filename) ---
+function memoExists(prefix) {
+ if (!existsSync(PENDING)) return false;
+ return readdirSync(PENDING).some(f => f.startsWith(prefix));
+}
+function draftMemo(kind, e, body) {
+ if (!existsSync(PENDING)) mkdirSync(PENDING, { recursive: true });
+ const safeId = String(e.id).replace(/[^a-zA-Z0-9._-]/g, "_");
+ const prefix = `fleet-${kind}-${safeId}`;
+ if (memoExists(prefix)) return { skipped: true }; // already pending — don't spam
+ const day = new Date().toISOString().slice(0, 10);
+ const file = join(PENDING, `${prefix}-${day}.md`);
+ if (APPLY) writeFileSync(file, body);
+ return { file, written: APPLY };
+}
+
+const out = { dark_safe_restarted: [], dark_gated: [], zombies: [], stale: [], errors: [] };
+
+for (const e of entries) {
+ // ZOMBIE: retired but still online
+ if (e.lifecycle_status === "retired" && e.live_status === "online") {
+ const m = draftMemo("zombie", e, zombieMemo(e));
+ out.zombies.push({ id: e.id, memo: m.file || "(pending)", skipped: m.skipped });
+ continue;
+ }
+ // STALE: deprecated past retire_after, OR expected_up never-seen in STALE_DAYS (and not schedule-healthy)
+ const pastRetire = e.lifecycle_status === "deprecated" && e.retire_after && daysSince(e.retire_after) > 0;
+ const ghosted = e.expected_up === true && !snapshotHealthy(e) && daysSince(e.last_seen) > STALE_DAYS;
+ if (pastRetire || ghosted) {
+ const m = draftMemo("retire", e, retireMemo(e, { pastRetire, ghosted }));
+ out.stale.push({ id: e.id, why: pastRetire ? "past retire_after" : `unseen ${STALE_DAYS}d+`, memo: m.file || "(pending)", skipped: m.skipped });
+ // ghosted is ALSO a dark alert — fall through to DARK handling below
+ if (!ghosted) continue;
+ }
+ // DARK: expected_up but not (truly) alive
+ if (e.expected_up === true) {
+ const alive = await trulyAlive(e);
+ if (alive) continue;
+ if (restartSafe(e)) {
+ const cmd = restartCmd(e);
+ if (!cmd) { out.dark_gated.push({ id: e.id, reason: "no restart cmd for kind" }); continue; }
+ if (APPLY) {
+ try { execSync(cmd, { stdio: "pipe", timeout: 20000 }); out.dark_safe_restarted.push({ id: e.id, cmd }); }
+ catch (err) { out.errors.push({ id: e.id, cmd, err: String(err.message || err).slice(0, 200) }); }
+ } else out.dark_safe_restarted.push({ id: e.id, cmd: `${cmd} (dry-run)` });
+ } else {
+ const m = draftMemo("dark", e, darkMemo(e));
+ out.dark_gated.push({ id: e.id, owner: e.owner, memo: m.file || "(pending)", skipped: m.skipped });
+ }
+ }
+}
+
+// --- memo bodies ---
+function head(e) {
+ return `- **Service:** \`${e.id}\` (${e.name}) on ${e.machine}, kind=${e.kind}\n` +
+ `- **Owner:** ${e.owner || "?"}\n- **live_status:** ${e.live_status} · **lifecycle:** ${e.lifecycle_status} · **last_seen:** ${e.last_seen || "?"}\n` +
+ (e.notes ? `- **Registry notes:** ${e.notes}\n` : "");
+}
+function darkMemo(e) {
+ return `# DARK ALERT — \`${e.id}\` is expected_up but down (GATED restart)\n\n${head(e)}\n` +
+ `**Why gated:** this service is customer-facing or mid-write (or on Kamatera), so the officer will NOT auto-bounce it — a half-applied restart can be worse than the gap (Q4=B).\n\n` +
+ `**To restart (after confirming it's idle / no mid-write):**\n\`\`\`sh\n${restartCmd(e) || "# (no standard restart for this kind — investigate)"}\n\`\`\`\n` +
+ `**To abort:** delete this memo.\n\n_Drafted by fleet-reconcile.mjs — Fleet Lifecycle Officer._\n`;
+}
+function zombieMemo(e) {
+ return `# ZOMBIE ALERT — \`${e.id}\` is RETIRED but still online (decommission target)\n\n${head(e)}\n` +
+ `**Meaning:** Steve decided to retire this, but it's still running — the decommission didn't finish, or something resurrected it.\n\n` +
+ `**To finish decommission (GATED — customer-facing teardown needs your sign-off):**\n\`\`\`sh\n` +
+ (e.kind === "pm2" ? `pm2 delete ${JSON.stringify(e.name)} && pm2 save` : `launchctl bootout gui/$(id -u)/${e.name}`) +
+ `\n\`\`\`\n**To keep it (reactivate):** set lifecycle_status back to active in overlay.json.\n**To abort:** delete this memo.\n\n_Drafted by fleet-reconcile.mjs._\n`;
+}
+function retireMemo(e, { pastRetire, ghosted }) {
+ return `# RETIREMENT CANDIDATE — \`${e.id}\` is old & irrelevant\n\n${head(e)}\n` +
+ `**Why flagged:** ${pastRetire ? `deprecated and past its retire_after (${e.retire_after})` : `expected_up but unseen for >${STALE_DAYS} days`}.\n\n` +
+ `**To retire (record in the ledger):** set \`lifecycle_status: retired\` + \`retire_after\` for \`${e.id}\` in \`~/Projects/_shared/fleet-registry/overlay.json\`.\n` +
+ `**Reversibility:** archive-before-delete — agent files → ~/.claude/agents/_archived/; \`pm2 resurrect\` / re-load plist to undo.\n` +
+ `**To keep it:** add it to safe-restart-allowlist or set expected_up appropriately.\n**To abort:** delete this memo.\n\n_Drafted by fleet-reconcile.mjs — NEVER retires autonomously (Steve approves)._\n`;
+}
+
+// --- report + run-log ---
+const summary = {
+ ts: new Date().toISOString(), mode: APPLY ? "apply" : "dry-run",
+ entries: entries.length,
+ dark_safe: out.dark_safe_restarted.length, dark_gated: out.dark_gated.length,
+ zombies: out.zombies.length, stale: out.stale.length, errors: out.errors.length,
+};
+try { appendFileSync(RUNLOG, JSON.stringify(summary) + "\n"); } catch {}
+console.log(`fleet-reconcile [${summary.mode}] — ${entries.length} real services`);
+console.log(` 🟢 safe auto-restart: ${out.dark_safe_restarted.length}`);
+out.dark_safe_restarted.forEach(d => console.log(` ${d.id} ${d.cmd}`));
+console.log(` 🔴 DARK gated (drafted/pending): ${out.dark_gated.length}`);
+out.dark_gated.slice(0, 10).forEach(d => console.log(` ${d.id} owner=${d.owner || "?"} ${d.skipped ? "(memo already pending)" : ""}`));
+console.log(` 🧟 ZOMBIE: ${out.zombies.length}`);
+out.zombies.slice(0, 10).forEach(d => console.log(` ${d.id} ${d.skipped ? "(pending)" : ""}`));
+console.log(` 🟡 retirement candidates: ${out.stale.length}`);
+out.stale.slice(0, 10).forEach(d => console.log(` ${d.id} (${d.why}) ${d.skipped ? "(pending)" : ""}`));
+if (out.errors.length) { console.log(` ⚠️ restart errors: ${out.errors.length}`); out.errors.forEach(e => console.log(` ${e.id}: ${e.err}`)); }
+console.log(` cost: $0 (local)`);
diff --git a/safe-restart-allowlist.json b/safe-restart-allowlist.json
new file mode 100644
index 0000000..2e9f580
--- /dev/null
+++ b/safe-restart-allowlist.json
@@ -0,0 +1,9 @@
+{
+ "_README": "Q4=B (DTD 2026-06-24): DEFAULT-DENY. A DARK service is auto-restarted by fleet-reconcile.mjs ONLY if its exact `id` is listed in `allow` below, it is on mac2, and it does NOT match any deny_pattern. Everything else is GATED — surfaced as a memo to ~/.claude/yolo-queue/pending-approval for Steve. Add an id here only once you trust it's safe & idempotent to bounce. Empty allow = nothing auto-restarts yet = maximally safe; the officer still alerts + drafts memos.",
+ "allow": [],
+ "deny_patterns": [
+ "cadence", "shopify", "aichat", "big[-_]?red", "storefront",
+ "new-?arrivals", "curator", "mailer", "drops", "instagram", "tiktok",
+ "checkout", "buybutton", "concierge", "rotation", "publish"
+ ]
+}
(oldest)
·
back to Fleet Lifecycle Steward
·
reconciler: UNHEALTHY tier — catch loaded-but-crash-looping e92fb43 →