muse-crew 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +11 -0
- package/docs/guide.md +24 -0
- package/lib/crew-api.js +45 -13
- package/package.json +1 -1
- package/seed/cron-body-template.md +6 -5
- package/seed/crons.json +1 -1
- package/workflows/crew-init.js +28 -8
- package/workflows/crew-uninstall.js +264 -0
package/API.md
CHANGED
|
@@ -293,6 +293,11 @@ Returns `{ ok: true, runs: [...], events: [...] }`.
|
|
|
293
293
|
|
|
294
294
|
The platform records workflow run status in `runtime.workflow_runs` — separate from our telemetry. When a workflow dies on a fatal `agent()` failure (e.g. "subagent bootstrap is no longer authorized"), our telemetry shows a run with no events, but the platform knows why. This monitor correlates platform failures with our tasks.
|
|
295
295
|
|
|
296
|
+
Two layers, in order of preference:
|
|
297
|
+
|
|
298
|
+
1. **Prevention — the launcher stays alive.** Async workflow `agent()` authorization is tied to the launcher's lifetime: if the cron tick ends while a workflow is still running, the workflow's next `agent()` call fails. So the poll tick runs with a 90-minute execution timeout (`timeout_secs: 5400` in `seed/crons.json`) and its Step 5 monitor stays alive until every launched run reaches a terminal state. A launcher that outlives its runs never triggers the failure in the first place.
|
|
299
|
+
2. **Recovery — per-tick correlation through the durable mapping.** If the launcher dies early anyway (platform kill, cell recycle), the next tick's Step 0 finds the dead run, `record-platform-failure` correlates it via the `platform_run_tasks` mapping, and `retry-platform-failure` requeues the task — at most ~3 minutes later, with the ghost session settled so the dispatcher treats it as a retry candidate immediately.
|
|
300
|
+
|
|
296
301
|
### `record-platform-failure`
|
|
297
302
|
|
|
298
303
|
Record a platform workflow run failure. Correlates the task in this order: (1) the durable `platform_run_tasks` mapping written by `acknowledge-dispatch-run`, (2) timestamp proximity (±60s) between `platform_created_at` and crew telemetry launch time (fallback for runs launched before the mapping existed), (3) an explicit `crew_run_id`/`task_id` when provided. A failure that correlates to no task is kept with `task_id: null` and reported as skipped by retry.
|
|
@@ -324,6 +329,12 @@ Returns `{ ok: true, failures: [...] }`.
|
|
|
324
329
|
|
|
325
330
|
Retry a task whose workflow died on a platform failure. Clears the stale reservation, re-queues the task to `todo`, and increments the retry count. If `retry_count` >= `max_retries` (default 3), parks the task instead.
|
|
326
331
|
|
|
332
|
+
**Ghost-session settlement:** the dead workflow's `agent_sessions` row still says `running`, and the dispatcher skips any task whose latest session is running ("work in flight"). The retry settles it atomically with the state change, so the task is redispatchable on the very next tick instead of waiting out the 1-hour zombie-session sweep:
|
|
333
|
+
- on `requeued`: running sessions → `stalled` (the dispatcher already treats `stalled` as a retry candidate);
|
|
334
|
+
- on `parked`: running sessions → `failed` (what `recover-task` accepts), same as `park-task`.
|
|
335
|
+
|
|
336
|
+
Returns `{ ok: true, action: "requeued"|"parked"|"skipped", ..., settled_sessions: <n> }`.
|
|
337
|
+
|
|
327
338
|
**Supersede guard:** if a newer platform run has been linked for this task since this failure's run, the task is already owned by the successor — the retry is skipped instead of clobbering live work (a late detection of an old dead run never resets a redispatched task).
|
|
328
339
|
|
|
329
340
|
| Field | Type | Notes |
|
package/docs/guide.md
CHANGED
|
@@ -99,6 +99,30 @@ After init completes, it returns a summary:
|
|
|
99
99
|
|
|
100
100
|
Re-running init against an existing setup returns `"unchanged"` (or `"updated"` with the converged field names) for each cron, with no other mutations.
|
|
101
101
|
|
|
102
|
+
### Uninstall
|
|
103
|
+
|
|
104
|
+
Uninstalling a crew removes its scheduler crons and deletes the crew home — one flow, never a manual two-step. Launch it as a workflow through the agent's workflow tools:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
workflow_launch with:
|
|
108
|
+
scriptPath: "<install-path>/workflows/crew-uninstall.js"
|
|
109
|
+
args: {
|
|
110
|
+
crewHome: "~/workspace/.my-crew",
|
|
111
|
+
confirm: true
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`crewHome` is required; `confirm` must be exactly `true` or the run blocks. `force` (default `false`) overrides the live-work check described below.
|
|
116
|
+
|
|
117
|
+
The run has four phases:
|
|
118
|
+
|
|
119
|
+
1. **Gates** — the crew home must be inside the workspace (uninstall refuses to delete anything outside it), and `confirm: true` must be present. Either missing blocks the run before anything is touched.
|
|
120
|
+
2. **Safety** — if any task is `in_progress`, the run blocks unless `force: true`. Uninstalling under live workflows would strand them.
|
|
121
|
+
3. **Crons** — removes the crew's scheduler jobs, and only those: the exact ids from `$CREW_HOME/.cron-registry.json` (written by init; covers id overrides), plus discovery of `crew-poll-*` jobs whose body contains the crew home path (covers installs from before the registry existed). Anything not on that union is never touched — shared crons are safe by construction, and a missing home still gets its orphaned crons removed.
|
|
122
|
+
4. **Home** — best-effort `git worktree prune` on the crew's project repos, then deletes the crew home directory and verifies it is gone.
|
|
123
|
+
|
|
124
|
+
Dashboard artifacts are left untouched — uninstall removes the crew instance (its crons and its home), never the user's artifacts.
|
|
125
|
+
|
|
102
126
|
## Connecting an existing project
|
|
103
127
|
|
|
104
128
|
Projects are registered through the API, not through init. Init sets up the infrastructure and registers the task service as the first project; you register additional projects separately.
|
package/lib/crew-api.js
CHANGED
|
@@ -847,14 +847,32 @@ commands["retry-platform-failure"] = (db, args) => {
|
|
|
847
847
|
if (task && task.state !== "parked") {
|
|
848
848
|
const msg = "Platform workflow failed " + (failure.retry_count + 1) + "x: " +
|
|
849
849
|
failure.error_message.substring(0, 200);
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
850
|
+
const ts = new Date().toISOString();
|
|
851
|
+
db.exec("BEGIN");
|
|
852
|
+
try {
|
|
853
|
+
db.prepare("UPDATE tasks SET state = 'parked', updated_at = datetime('now') WHERE id = ?")
|
|
854
|
+
.run(failure.task_id);
|
|
855
|
+
db.prepare("DELETE FROM dispatch_reservations WHERE task_id = ?").run(failure.task_id);
|
|
856
|
+
// Settle the ghost session: a parked task never keeps a running
|
|
857
|
+
// session, and 'failed' is what recover-task accepts.
|
|
858
|
+
const settled = db.prepare(
|
|
859
|
+
`UPDATE agent_sessions
|
|
860
|
+
SET status = 'failed', ended_at = ?,
|
|
861
|
+
notes = COALESCE(notes, '') || ' Parked: platform retry exhausted.'
|
|
862
|
+
WHERE task_id = ? AND status = 'running'`
|
|
863
|
+
).run(ts, failure.task_id);
|
|
864
|
+
db.prepare(
|
|
865
|
+
`INSERT INTO events (id, type, task_id, identity, message, timestamp)
|
|
866
|
+
VALUES (?, 'note', ?, NULL, ?, ?)`
|
|
867
|
+
).run(uuid(), failure.task_id, "Parked: " + msg, ts);
|
|
868
|
+
db.exec("COMMIT");
|
|
869
|
+
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: Number(settled.changes) };
|
|
870
|
+
} catch (e) {
|
|
871
|
+
db.exec("ROLLBACK");
|
|
872
|
+
throw e;
|
|
873
|
+
}
|
|
856
874
|
}
|
|
857
|
-
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count };
|
|
875
|
+
return { ok: true, action: "parked", reason: "max retries exceeded", retry_count: failure.retry_count, settled_sessions: 0 };
|
|
858
876
|
}
|
|
859
877
|
// Clear reservation and re-queue for retry.
|
|
860
878
|
db.exec("BEGIN");
|
|
@@ -863,22 +881,36 @@ commands["retry-platform-failure"] = (db, args) => {
|
|
|
863
881
|
db.prepare(
|
|
864
882
|
"UPDATE tasks SET state = 'todo', updated_at = datetime('now') WHERE id = ? AND state != 'done'"
|
|
865
883
|
).run(failure.task_id);
|
|
884
|
+
// Settle the ghost session atomically with the requeue: the platform
|
|
885
|
+
// workflow is dead, but its agent_sessions row still says 'running' —
|
|
886
|
+
// and the dispatcher skips any task whose latest session is running
|
|
887
|
+
// (crew-dispatch.js "work in flight"). Without this, the requeued task
|
|
888
|
+
// would sit until the 1-hour zombie-session sweep. 'stalled' is what
|
|
889
|
+
// the dispatcher already treats as a retry candidate.
|
|
890
|
+
const ts = new Date().toISOString();
|
|
891
|
+
const settled = db.prepare(
|
|
892
|
+
`UPDATE agent_sessions
|
|
893
|
+
SET status = 'stalled', ended_at = ?,
|
|
894
|
+
notes = COALESCE(notes, '') || ' Platform retry: launcher died; session settled for redispatch.'
|
|
895
|
+
WHERE task_id = ? AND status = 'running'`
|
|
896
|
+
).run(ts, failure.task_id);
|
|
866
897
|
db.prepare(
|
|
867
898
|
`UPDATE platform_run_failures
|
|
868
899
|
SET retry_count = retry_count + 1, last_retry_at = datetime('now')
|
|
869
900
|
WHERE platform_run_id = ?`
|
|
870
901
|
).run(args.platform_run_id);
|
|
871
902
|
db.exec("COMMIT");
|
|
903
|
+
return {
|
|
904
|
+
ok: true,
|
|
905
|
+
action: "requeued",
|
|
906
|
+
task_id: failure.task_id,
|
|
907
|
+
retry_count: failure.retry_count + 1,
|
|
908
|
+
settled_sessions: Number(settled.changes),
|
|
909
|
+
};
|
|
872
910
|
} catch (e) {
|
|
873
911
|
db.exec("ROLLBACK");
|
|
874
912
|
throw e;
|
|
875
913
|
}
|
|
876
|
-
return {
|
|
877
|
-
ok: true,
|
|
878
|
-
action: "requeued",
|
|
879
|
-
task_id: failure.task_id,
|
|
880
|
-
retry_count: failure.retry_count + 1,
|
|
881
|
-
};
|
|
882
914
|
};
|
|
883
915
|
|
|
884
916
|
commands["park-task"] = (db, args) => {
|
package/package.json
CHANGED
|
@@ -36,21 +36,22 @@ You are the dispatch trigger for Muse Crew. Run the authoritative dispatcher wor
|
|
|
36
36
|
|
|
37
37
|
If the dispatcher returned no claims or the claims array is empty, report: NO_DISPATCH and exit.
|
|
38
38
|
|
|
39
|
-
5. **Monitor launched workflows
|
|
40
|
-
- For each launched run_id, poll its status every ~
|
|
39
|
+
5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
|
|
40
|
+
- For each launched run_id, poll its status every ~2 minutes via muse.db:
|
|
41
41
|
```sql
|
|
42
42
|
SELECT status, error FROM runtime.workflow_runs WHERE run_id = '<run_id>'
|
|
43
43
|
```
|
|
44
44
|
- **If status is `completed`:** Done. Log success and stop monitoring this run.
|
|
45
45
|
- **If status is `failed`:** Check if it's a platform `agent()` error (error contains "subagent bootstrap", "reservation owner is terminal", "bootstrap was cancelled", or "workflow agent call failed"):
|
|
46
|
-
- **Platform error:** Record it and retry immediately (same two commands as Step 0). If the retry says `requeued`
|
|
46
|
+
- **Platform error:** Record it and retry immediately (same two commands as Step 0). If the retry says `requeued` and retries remain (max 3 per task):
|
|
47
47
|
- Re-acquire the reservation: `node {crewHome}/lib/crew-api.js --crew-home {crewHome} reserve-dispatch --json '{"task_id": "<task_id>"}'`
|
|
48
48
|
- If `acquired` is true, re-launch via workflow_launch_async with the same scriptPath and args, acknowledge with the new run_id, and continue monitoring the NEW run_id.
|
|
49
49
|
- If `acquired` is false, stop — another dispatcher claimed it.
|
|
50
|
-
If the retry result says `parked` (
|
|
50
|
+
If the retry result says `parked` (attempts exhausted), stop monitoring this task.
|
|
51
51
|
- **Task-level error (not a platform error):** The workflow's own error handling applies. Stop monitoring this run.
|
|
52
52
|
- **If status is `running` or `paused`:** Continue polling.
|
|
53
|
-
- **
|
|
53
|
+
- **Monitor ceiling:** 90 minutes from tick start. If a run is still not terminal then (pathological — the work phase caps each `agent()` call at 60 minutes), exit; the next tick's Step 0 continues recovery through the durable mapping.
|
|
54
|
+
- **Backstop (not the plan, the insurance):** If THIS launcher dies early for any reason (platform kill, cell recycle), the next tick's Step 0 detects the dead run through the durable platform run -> task mapping and retries it — at most ~3 minutes later. The mapping exists so a dead launcher never strands a task for an hour; the monitor exists so the launcher rarely dies mid-run in the first place.
|
|
54
55
|
- When all launched workflows are terminal or retry-exhausted, exit silently.
|
|
55
56
|
|
|
56
57
|
6. Exit silently.
|
package/seed/crons.json
CHANGED
package/workflows/crew-init.js
CHANGED
|
@@ -363,9 +363,18 @@ try {
|
|
|
363
363
|
" Call cron_update with only the fields that differ. If nothing differs,\n" +
|
|
364
364
|
" do not call cron_update. Record action 'updated' with the updated field\n" +
|
|
365
365
|
" names, or 'unchanged' with no updated fields.\n" +
|
|
366
|
-
"3.
|
|
367
|
-
"
|
|
368
|
-
"
|
|
366
|
+
"3. Write the cron registry at " + crewHome + "/.cron-registry.json — the exact\n" +
|
|
367
|
+
" record a later uninstall uses to find these jobs. Build this JSON (one entry\n" +
|
|
368
|
+
" per manifest entry, in manifest order, using the live ids from step 2):\n" +
|
|
369
|
+
" {\"version\":1,\"instanceId\":\"" + instanceId + "\",\"owner\":\"" + cronOwner + "\",\n" +
|
|
370
|
+
" \"crons\":[{\"id\":<live id>,\"manifestId\":<entry.id>}, ...]}\n" +
|
|
371
|
+
" Write it with printf '%s' and a single-quoted heredoc so the shell\n" +
|
|
372
|
+
" interpolates nothing, then read it back to confirm it parses.\n" +
|
|
373
|
+
"4. Return { passed: true, summary: { crons: [...], registry: {...} } } with one\n" +
|
|
374
|
+
" crons entry per manifest entry, in manifest order:\n" +
|
|
375
|
+
" { id, action: 'created' | 'updated' | 'unchanged', updated_fields: [...] },\n" +
|
|
376
|
+
" and registry: { path: \"" + crewHome + "/.cron-registry.json\",\n" +
|
|
377
|
+
" ids: [<live id>, ...] } echoing the ids written to disk.",
|
|
369
378
|
{
|
|
370
379
|
key: "crons-1",
|
|
371
380
|
label: "Create and converge cron jobs",
|
|
@@ -387,9 +396,17 @@ try {
|
|
|
387
396
|
},
|
|
388
397
|
required: ["id", "action", "updated_fields"]
|
|
389
398
|
}
|
|
399
|
+
},
|
|
400
|
+
registry: {
|
|
401
|
+
type: "object",
|
|
402
|
+
properties: {
|
|
403
|
+
path: { type: "string" },
|
|
404
|
+
ids: { type: "array", items: { type: "string" } }
|
|
405
|
+
},
|
|
406
|
+
required: ["path", "ids"]
|
|
390
407
|
}
|
|
391
408
|
},
|
|
392
|
-
required: ["crons"]
|
|
409
|
+
required: ["crons", "registry"]
|
|
393
410
|
}
|
|
394
411
|
},
|
|
395
412
|
required: ["passed", "summary"]
|
|
@@ -399,10 +416,12 @@ try {
|
|
|
399
416
|
} catch (e) {
|
|
400
417
|
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron setup failed", message: String(e.message || e) } };
|
|
401
418
|
}
|
|
402
|
-
if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.crons)
|
|
403
|
-
|
|
419
|
+
if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.crons) ||
|
|
420
|
+
!cronsResult.summary.registry || !Array.isArray(cronsResult.summary.registry.ids)) {
|
|
421
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron manifest invalid", message: "seed/crons.json failed validation, produced no crons list, or wrote no cron registry" } };
|
|
404
422
|
}
|
|
405
|
-
log("Crons: " + cronsResult.summary.crons.map(function (c) { return c.id + "=" + c.action; }).join(", ")
|
|
423
|
+
log("Crons: " + cronsResult.summary.crons.map(function (c) { return c.id + "=" + c.action; }).join(", ") +
|
|
424
|
+
"; registry: " + cronsResult.summary.registry.path + " (" + cronsResult.summary.registry.ids.join(", ") + ")");
|
|
406
425
|
|
|
407
426
|
|
|
408
427
|
// ── Summary ───────────────────────────────────────────────────────────
|
|
@@ -416,5 +435,6 @@ return {
|
|
|
416
435
|
releaseHash: releaseResult.hash,
|
|
417
436
|
scaffold: { created: scaffoldCreated, skipped: scaffoldSkipped },
|
|
418
437
|
project: projectResult.action,
|
|
419
|
-
crons: cronsResult.summary.crons
|
|
438
|
+
crons: cronsResult.summary.crons,
|
|
439
|
+
registry: cronsResult.summary.registry
|
|
420
440
|
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: "crew-uninstall",
|
|
3
|
+
description: "Uninstall a Muse Crew instance: remove its scheduler cron jobs, then delete the crew home directory. Destructive and irreversible — requires confirm: true, and refuses to run while any task is in_progress unless force: true. Only the crew's own crons are removed (exact registry plus body-match discovery); shared crons are never touched. Dashboard artifacts are left alone.",
|
|
4
|
+
phases: [
|
|
5
|
+
{ name: "gates", title: "Containment gate and explicit confirmation" },
|
|
6
|
+
{ name: "safety", title: "Refuse while work is live (unless forced)" },
|
|
7
|
+
{ name: "crons", title: "Remove the crew's scheduler crons" },
|
|
8
|
+
{ name: "home", title: "Delete the crew home directory" }
|
|
9
|
+
]
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// ── Arguments ──────────────────────────────────────────────────────────
|
|
13
|
+
const inputs = args ?? {};
|
|
14
|
+
const crewHome = inputs.crewHome;
|
|
15
|
+
const confirm = inputs.confirm;
|
|
16
|
+
const force = inputs.force;
|
|
17
|
+
|
|
18
|
+
if (!crewHome) throw new Error("crewHome is required — e.g. ~/workspace/.crew");
|
|
19
|
+
|
|
20
|
+
// ── Pure decision helpers ──────────────────────────────────────────────
|
|
21
|
+
// The agent is a sensor, not a judge: it reports raw facts (HOME, expanded
|
|
22
|
+
// paths, existence, in-progress counts) and the verdicts below are computed
|
|
23
|
+
// here, deterministically. (Prompt hardening is a smell — this is the
|
|
24
|
+
// mechanical version.)
|
|
25
|
+
function expandTilde(p, home) {
|
|
26
|
+
if (p === "~") return home;
|
|
27
|
+
if (p.indexOf("~/") === 0) return home + p.slice(1);
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
function uninstallGateDecide(facts) {
|
|
31
|
+
// facts: { home, crewHomeExpanded }
|
|
32
|
+
// Uninstall deletes a directory tree — it must never run against a home
|
|
33
|
+
// outside the workspace. Same prefix rule as crew-init's Gate 0.
|
|
34
|
+
var workspace = facts.home + "/workspace";
|
|
35
|
+
var launchable = facts.crewHomeExpanded.indexOf(workspace + "/") === 0;
|
|
36
|
+
return { workspace: workspace, launchable: launchable };
|
|
37
|
+
}
|
|
38
|
+
function confirmDecide(c) {
|
|
39
|
+
// Strict: only the boolean true counts. "true", 1, and undefined do not.
|
|
40
|
+
if (c === true) return { ok: true, message: "" };
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
message: "crew-uninstall is destructive and irreversible: it removes the crew's scheduler cron jobs and deletes the crew home directory. Re-run with confirm: true to proceed."
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function safetyDecide(inProgressCount, f) {
|
|
47
|
+
// Live work strands if its home disappears mid-run. Refuse unless forced.
|
|
48
|
+
if (inProgressCount > 0 && f !== true) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
message: inProgressCount + " task(s) are in_progress. Uninstalling now would strand their workflows. Re-run with force: true to uninstall anyway, or finish/park the work first."
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, message: "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Gates: containment + confirmation ──────────────────────────────────
|
|
58
|
+
phase("gates");
|
|
59
|
+
var gateFacts;
|
|
60
|
+
try {
|
|
61
|
+
gateFacts = await agent(
|
|
62
|
+
"Report filesystem facts for a crew uninstall. Do not judge — just report.\n\n" +
|
|
63
|
+
"Requested crewHome: " + crewHome + "\n\n" +
|
|
64
|
+
"Steps (run in shell):\n" +
|
|
65
|
+
"1. home=$(echo $HOME)\n" +
|
|
66
|
+
"2. Expand a leading ~/ in the requested crewHome against $HOME (leave other paths untouched).\n" +
|
|
67
|
+
"3. Report whether the expanded path exists: test -e \"<expanded>\"; echo EXISTS:$?\n" +
|
|
68
|
+
"4. Return JSON { home, crewHomeExpanded, homeExists } where homeExists is true\n" +
|
|
69
|
+
" when the path exists.",
|
|
70
|
+
{
|
|
71
|
+
key: "uninstall-gates",
|
|
72
|
+
label: "Validate uninstall target",
|
|
73
|
+
schema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
home: { type: "string" },
|
|
77
|
+
crewHomeExpanded: { type: "string" },
|
|
78
|
+
homeExists: { type: "boolean" }
|
|
79
|
+
},
|
|
80
|
+
required: ["home", "crewHomeExpanded", "homeExists"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Uninstall validation failed", message: String(e.message || e) } };
|
|
86
|
+
}
|
|
87
|
+
var gateResult = uninstallGateDecide(gateFacts);
|
|
88
|
+
if (!gateResult.launchable) {
|
|
89
|
+
return {
|
|
90
|
+
__hatchWorkflowControl: "blocked",
|
|
91
|
+
result: {
|
|
92
|
+
blocked_reason: "crewHome is not workspace-contained",
|
|
93
|
+
message: "crewHome must be inside the workspace (" + gateResult.workspace + "). Requested: " + gateFacts.crewHomeExpanded + ". Uninstall refuses to delete anything outside the workspace."
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
var crewHomeExpanded = gateFacts.crewHomeExpanded;
|
|
98
|
+
var confirmation = confirmDecide(confirm);
|
|
99
|
+
if (!confirmation.ok) {
|
|
100
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Uninstall not confirmed", message: confirmation.message } };
|
|
101
|
+
}
|
|
102
|
+
log("Uninstall confirmed for " + crewHomeExpanded + (gateFacts.homeExists ? "" : " (crew home already gone — removing orphaned crons only)"));
|
|
103
|
+
|
|
104
|
+
// ── Safety: refuse while work is live (unless forced) ───────────────────
|
|
105
|
+
phase("safety");
|
|
106
|
+
var safetyFacts = { homeExists: gateFacts.homeExists, inProgress: 0 };
|
|
107
|
+
if (gateFacts.homeExists) {
|
|
108
|
+
try {
|
|
109
|
+
safetyFacts = await agent(
|
|
110
|
+
"Report whether this crew has live work. Do not judge — just report.\n\n" +
|
|
111
|
+
"crewHome: " + crewHomeExpanded + "\n\n" +
|
|
112
|
+
"Steps (run in shell):\n" +
|
|
113
|
+
"1. If \"" + crewHomeExpanded + "/crew-state.db\" exists, run:\n" +
|
|
114
|
+
" sqlite3 \"" + crewHomeExpanded + "/crew-state.db\" \"SELECT COUNT(*) FROM tasks WHERE state='in_progress';\"\n" +
|
|
115
|
+
" Report the number. When the database is missing, the count is 0.\n" +
|
|
116
|
+
"2. Return JSON { homeExists: true, inProgress: <number> }.",
|
|
117
|
+
{
|
|
118
|
+
key: "uninstall-safety",
|
|
119
|
+
label: "Check for live work",
|
|
120
|
+
schema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
homeExists: { type: "boolean" },
|
|
124
|
+
inProgress: { type: "number" }
|
|
125
|
+
},
|
|
126
|
+
required: ["homeExists", "inProgress"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Safety check failed", message: String(e.message || e) } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
var safety = safetyDecide(safetyFacts.inProgress, force);
|
|
135
|
+
if (!safety.ok) {
|
|
136
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Live work in progress", message: safety.message } };
|
|
137
|
+
}
|
|
138
|
+
log("Safety: " + safetyFacts.inProgress + " in_progress task(s)" + (force === true ? " (forced)" : ""));
|
|
139
|
+
|
|
140
|
+
// ── Remove the crew's scheduler crons ──────────────────────────────────
|
|
141
|
+
// Two sources, unioned: the exact registry written by crew-init (covers id
|
|
142
|
+
// overrides), and discovery (covers installs from before the registry
|
|
143
|
+
// existed, and a registry that was never written). A job is only removed
|
|
144
|
+
// when it is on that union — never by guesswork.
|
|
145
|
+
phase("crons");
|
|
146
|
+
var cronsResult;
|
|
147
|
+
try {
|
|
148
|
+
cronsResult = await agent(
|
|
149
|
+
"Remove this crew instance's scheduler cron jobs — and nothing else.\n\n" +
|
|
150
|
+
"crewHome: " + crewHomeExpanded + "\n\n" +
|
|
151
|
+
"Steps:\n" +
|
|
152
|
+
"1. Registry: if \"" + crewHomeExpanded + "/.cron-registry.json\" exists, read it\n" +
|
|
153
|
+
" and take its crons[].id list as registry candidates.\n" +
|
|
154
|
+
"2. Discovery: call cron_list. For every job whose id starts with 'crew-poll-',\n" +
|
|
155
|
+
" call cron_view and keep it as a discovery candidate when the job body\n" +
|
|
156
|
+
" contains the crewHome string '" + crewHomeExpanded + "' (the poll body template\n" +
|
|
157
|
+
" embeds the crew home path). The bare id 'crew-poll' with no instance suffix\n" +
|
|
158
|
+
" is never a live crew job — never remove it.\n" +
|
|
159
|
+
"3. Removal list = registry candidates ∪ discovery candidates, deduped.\n" +
|
|
160
|
+
" SAFETY: remove only jobs on this list. When in doubt about any job, fail\n" +
|
|
161
|
+
" closed (passed: false) instead of guessing.\n" +
|
|
162
|
+
"4. For each id on the removal list: call cron_remove. A job that is already\n" +
|
|
163
|
+
" gone records action 'already_gone'.\n" +
|
|
164
|
+
"5. Call cron_list again and confirm none of the removed ids remain. If any\n" +
|
|
165
|
+
" remain, return passed: false.\n" +
|
|
166
|
+
"6. Return { passed: true, summary: { removed: [...] } } with one entry per\n" +
|
|
167
|
+
" removal-list id: { id, source: 'registry' | 'discovery',\n" +
|
|
168
|
+
" action: 'removed' | 'already_gone' }. An empty removal list is a pass\n" +
|
|
169
|
+
" with removed: [].",
|
|
170
|
+
{
|
|
171
|
+
key: "uninstall-crons",
|
|
172
|
+
label: "Remove the crew's crons",
|
|
173
|
+
schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
passed: { type: "boolean" },
|
|
177
|
+
summary: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
removed: {
|
|
181
|
+
type: "array",
|
|
182
|
+
items: {
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: {
|
|
185
|
+
id: { type: "string" },
|
|
186
|
+
source: { type: "string", enum: ["registry", "discovery"] },
|
|
187
|
+
action: { type: "string", enum: ["removed", "already_gone"] }
|
|
188
|
+
},
|
|
189
|
+
required: ["id", "source", "action"]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
required: ["removed"]
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
required: ["passed", "summary"]
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron removal failed", message: String(e.message || e) } };
|
|
202
|
+
}
|
|
203
|
+
if (!cronsResult.passed || !cronsResult.summary || !Array.isArray(cronsResult.summary.removed)) {
|
|
204
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Cron removal failed", message: "The cron-removal agent did not pass. No further teardown was attempted; the crew home is untouched." } };
|
|
205
|
+
}
|
|
206
|
+
var removedDesc = cronsResult.summary.removed.map(function (r) { return r.id + "=" + r.action; }).join(", ");
|
|
207
|
+
log("Crons: " + (removedDesc || "(none belonged to this crew)"));
|
|
208
|
+
|
|
209
|
+
// ── Delete the crew home directory ─────────────────────────────────────
|
|
210
|
+
phase("home");
|
|
211
|
+
var homeRemoved = false;
|
|
212
|
+
if (gateFacts.homeExists) {
|
|
213
|
+
var homeResult;
|
|
214
|
+
try {
|
|
215
|
+
homeResult = await agent(
|
|
216
|
+
"Delete the crew home directory.\n\n" +
|
|
217
|
+
"crewHome: " + crewHomeExpanded + "\n\n" +
|
|
218
|
+
"Steps:\n" +
|
|
219
|
+
"1. Best-effort worktree hygiene (failures do not block): if\n" +
|
|
220
|
+
" \"" + crewHomeExpanded + "/crew-state.db\" exists, list the repo_path values\n" +
|
|
221
|
+
" from its projects table and run 'git worktree prune' in each. Ignore errors.\n" +
|
|
222
|
+
"2. Run: rm -rf \"" + crewHomeExpanded + "\"\n" +
|
|
223
|
+
"3. Verify: test ! -e \"" + crewHomeExpanded + "\" — fail closed (passed: false)\n" +
|
|
224
|
+
" if the path still exists.\n" +
|
|
225
|
+
"4. Return { passed: true, summary: { removed: true } }.",
|
|
226
|
+
{
|
|
227
|
+
key: "uninstall-home",
|
|
228
|
+
label: "Delete the crew home",
|
|
229
|
+
schema: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
passed: { type: "boolean" },
|
|
233
|
+
summary: {
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: {
|
|
236
|
+
removed: { type: "boolean" }
|
|
237
|
+
},
|
|
238
|
+
required: ["removed"]
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
required: ["passed", "summary"]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Crew home removal failed", message: String(e.message || e) } };
|
|
247
|
+
}
|
|
248
|
+
if (!homeResult.passed || !homeResult.summary || homeResult.summary.removed !== true) {
|
|
249
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Crew home removal failed", message: "The crew home could not be deleted. Its crons were already removed; delete " + crewHomeExpanded + " manually." } };
|
|
250
|
+
}
|
|
251
|
+
homeRemoved = true;
|
|
252
|
+
log("Crew home deleted: " + crewHomeExpanded);
|
|
253
|
+
} else {
|
|
254
|
+
log("Crew home already gone — nothing to delete.");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Summary ────────────────────────────────────────────────────────────
|
|
258
|
+
return {
|
|
259
|
+
message: "Muse Crew uninstalled.",
|
|
260
|
+
crewHome: crewHomeExpanded,
|
|
261
|
+
removed_crons: cronsResult.summary.removed,
|
|
262
|
+
crew_home_removed: homeRemoved,
|
|
263
|
+
note: "Dashboard artifacts were left untouched — uninstall removes the crew instance (its crons and its home), never the user's artifacts."
|
|
264
|
+
};
|