gsd-pi 2.78.1-dev.e9d88a536 → 2.78.1-dev.eccf86e27
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/README.md +5 -7
- package/dist/help-text.js +1 -1
- package/dist/resource-loader.js +6 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
- package/dist/resources/extensions/gsd/auto/loop.js +235 -36
- package/dist/resources/extensions/gsd/auto/phases.js +14 -7
- package/dist/resources/extensions/gsd/auto/session.js +36 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +49 -4
- package/dist/resources/extensions/gsd/auto-post-unit.js +26 -12
- package/dist/resources/extensions/gsd/auto-worktree.js +185 -201
- package/dist/resources/extensions/gsd/auto.js +139 -49
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +26 -20
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
- package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
- package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
- package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
- package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
- package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
- package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
- package/dist/resources/extensions/gsd/db-writer.js +96 -16
- package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
- package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
- package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
- package/dist/resources/extensions/gsd/doctor.js +12 -2
- package/dist/resources/extensions/gsd/gsd-db.js +355 -3
- package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
- package/dist/resources/extensions/gsd/guided-flow.js +116 -26
- package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
- package/dist/resources/extensions/gsd/metrics.js +287 -1
- package/dist/resources/extensions/gsd/paths.js +79 -8
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
- package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
- package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
- package/dist/resources/extensions/gsd/state.js +21 -6
- package/dist/resources/extensions/gsd/templates/project.md +10 -0
- package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
- package/dist/resources/extensions/gsd/workspace.js +59 -0
- package/dist/resources/extensions/gsd/worktree-resolver.js +79 -2
- package/dist/resources/extensions/gsd/write-intercept.js +3 -3
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/README.md +2 -11
- package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
- package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
- package/packages/mcp-server/dist/remote-questions.js +28 -0
- package/packages/mcp-server/dist/remote-questions.js.map +1 -1
- package/packages/mcp-server/dist/server.d.ts +28 -0
- package/packages/mcp-server/dist/server.d.ts.map +1 -1
- package/packages/mcp-server/dist/server.js +94 -4
- package/packages/mcp-server/dist/server.js.map +1 -1
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/src/mcp-server.test.ts +226 -0
- package/packages/mcp-server/src/remote-questions.test.ts +103 -0
- package/packages/mcp-server/src/remote-questions.ts +35 -0
- package/packages/mcp-server/src/server.ts +129 -6
- package/packages/mcp-server/src/workflow-tools.ts +1 -1
- package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
- package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
- package/src/resources/extensions/gsd/auto/loop.ts +263 -41
- package/src/resources/extensions/gsd/auto/phases.ts +15 -7
- package/src/resources/extensions/gsd/auto/session.ts +40 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +63 -4
- package/src/resources/extensions/gsd/auto-post-unit.ts +27 -12
- package/src/resources/extensions/gsd/auto-worktree.ts +218 -225
- package/src/resources/extensions/gsd/auto.ts +166 -43
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +26 -21
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
- package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
- package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
- package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
- package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
- package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
- package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
- package/src/resources/extensions/gsd/db-writer.ts +113 -17
- package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
- package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
- package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
- package/src/resources/extensions/gsd/doctor.ts +10 -2
- package/src/resources/extensions/gsd/gsd-db.ts +354 -3
- package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
- package/src/resources/extensions/gsd/guided-flow.ts +152 -26
- package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
- package/src/resources/extensions/gsd/metrics.ts +321 -1
- package/src/resources/extensions/gsd/paths.ts +67 -8
- package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
- package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
- package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
- package/src/resources/extensions/gsd/state.ts +44 -6
- package/src/resources/extensions/gsd/templates/project.md +10 -0
- package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
- package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
- package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
- package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
- package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
- package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
- package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
- package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
- package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
- package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
- package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
- package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
- package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
- package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
- package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
- package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
- package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
- package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
- package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
- package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
- package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +369 -0
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
- package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
- package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
- package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
- package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
- package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
- package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
- package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
- package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
- package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
- package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
- package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +138 -16
- package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
- package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
- package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
- package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
- package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +434 -0
- package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
- package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +98 -0
- package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
- package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
- package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
- package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/workspace.test.ts +196 -0
- package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +94 -71
- package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
- package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
- package/src/resources/extensions/gsd/workspace.ts +95 -0
- package/src/resources/extensions/gsd/worktree-resolver.ts +78 -2
- package/src/resources/extensions/gsd/write-intercept.ts +3 -3
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
- package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
- /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// gsd-2 + Unit dispatch ledger (DB-backed coordination, Phase B)
|
|
2
|
+
//
|
|
3
|
+
// Records every auto-mode unit dispatch (plan-slice, run-task, summarize, …)
|
|
4
|
+
// with worker_id, fencing token, status lifecycle, and retry metadata. The
|
|
5
|
+
// ledger is the substrate Phase C will consume to migrate stuck-state.json
|
|
6
|
+
// and paused-session.json out of the runtime/ directory.
|
|
7
|
+
//
|
|
8
|
+
// Codex review MEDIUM B2: partial unique index
|
|
9
|
+
// idx_unit_dispatches_active_per_unit ON unit_dispatches(unit_id)
|
|
10
|
+
// WHERE status IN ('claimed','running')
|
|
11
|
+
// enforces that two workers cannot simultaneously claim the same unit.
|
|
12
|
+
// recordDispatchClaim relies on the index to fail fast at INSERT time
|
|
13
|
+
// rather than racing in application code.
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { _getAdapter, isDbAvailable, transaction, insertAuditEvent, } from "../gsd-db.js";
|
|
16
|
+
function isAlreadyActiveConstraintError(err) {
|
|
17
|
+
const code = err && typeof err === "object" && "code" in err
|
|
18
|
+
? String(err.code ?? "")
|
|
19
|
+
: "";
|
|
20
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
21
|
+
if (/\bFOREIGN KEY\b/i.test(msg)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (code === "SQLITE_CONSTRAINT" || code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return /\bUNIQUE\b|\bconstraint failed\b/i.test(msg);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Insert a new dispatch row in `claimed` state. Atomic guard against
|
|
31
|
+
* double-claim (B2): the partial unique index
|
|
32
|
+
* idx_unit_dispatches_active_per_unit refuses the INSERT if any row for
|
|
33
|
+
* the same unit_id already has status IN ('claimed','running').
|
|
34
|
+
*/
|
|
35
|
+
export function recordDispatchClaim(input) {
|
|
36
|
+
if (!isDbAvailable()) {
|
|
37
|
+
throw new Error("recordDispatchClaim: DB unavailable");
|
|
38
|
+
}
|
|
39
|
+
const now = new Date().toISOString();
|
|
40
|
+
return transaction(() => {
|
|
41
|
+
const db = _getAdapter();
|
|
42
|
+
const lease = db.prepare(`SELECT fencing_token
|
|
43
|
+
FROM milestone_leases
|
|
44
|
+
WHERE milestone_id = :milestone_id
|
|
45
|
+
AND worker_id = :worker_id
|
|
46
|
+
AND fencing_token = :token
|
|
47
|
+
AND status = 'held'`).get({
|
|
48
|
+
":milestone_id": input.milestoneId,
|
|
49
|
+
":worker_id": input.workerId,
|
|
50
|
+
":token": input.milestoneLeaseToken,
|
|
51
|
+
});
|
|
52
|
+
if (!lease) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: "stale_lease",
|
|
56
|
+
milestoneId: input.milestoneId,
|
|
57
|
+
workerId: input.workerId,
|
|
58
|
+
milestoneLeaseToken: input.milestoneLeaseToken,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = db.prepare(`INSERT INTO unit_dispatches (
|
|
63
|
+
trace_id, turn_id, worker_id, milestone_lease_token,
|
|
64
|
+
milestone_id, slice_id, task_id,
|
|
65
|
+
unit_type, unit_id, status, attempt_n,
|
|
66
|
+
started_at, max_attempts
|
|
67
|
+
) VALUES (
|
|
68
|
+
:trace_id, :turn_id, :worker_id, :milestone_lease_token,
|
|
69
|
+
:milestone_id, :slice_id, :task_id,
|
|
70
|
+
:unit_type, :unit_id, 'claimed', :attempt_n,
|
|
71
|
+
:started_at, :max_attempts
|
|
72
|
+
)`).run({
|
|
73
|
+
":trace_id": input.traceId,
|
|
74
|
+
":turn_id": input.turnId ?? null,
|
|
75
|
+
":worker_id": input.workerId,
|
|
76
|
+
":milestone_lease_token": input.milestoneLeaseToken,
|
|
77
|
+
":milestone_id": input.milestoneId,
|
|
78
|
+
":slice_id": input.sliceId ?? null,
|
|
79
|
+
":task_id": input.taskId ?? null,
|
|
80
|
+
":unit_type": input.unitType,
|
|
81
|
+
":unit_id": input.unitId,
|
|
82
|
+
":attempt_n": input.attemptN ?? 1,
|
|
83
|
+
":started_at": now,
|
|
84
|
+
":max_attempts": input.maxAttempts ?? 3,
|
|
85
|
+
});
|
|
86
|
+
const id = Number(result.lastInsertRowid ?? 0);
|
|
87
|
+
insertAuditEvent({
|
|
88
|
+
eventId: randomUUID(),
|
|
89
|
+
traceId: input.traceId,
|
|
90
|
+
turnId: input.turnId ?? undefined,
|
|
91
|
+
category: "orchestration",
|
|
92
|
+
type: "dispatch-claimed",
|
|
93
|
+
ts: now,
|
|
94
|
+
payload: {
|
|
95
|
+
dispatchId: id,
|
|
96
|
+
unitId: input.unitId,
|
|
97
|
+
unitType: input.unitType,
|
|
98
|
+
workerId: input.workerId,
|
|
99
|
+
attemptN: input.attemptN ?? 1,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return { ok: true, dispatchId: id };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (!isAlreadyActiveConstraintError(err))
|
|
106
|
+
throw err;
|
|
107
|
+
// Partial unique index rejected the INSERT — surface the existing
|
|
108
|
+
// active dispatch so callers can decide what to do.
|
|
109
|
+
const existing = db.prepare(`SELECT id, status, worker_id FROM unit_dispatches
|
|
110
|
+
WHERE unit_id = :unit_id AND status IN ('claimed','running')
|
|
111
|
+
ORDER BY id DESC LIMIT 1`).get({ ":unit_id": input.unitId });
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: "already_active",
|
|
115
|
+
existingId: existing?.id ?? 0,
|
|
116
|
+
existingStatus: existing?.status ?? "claimed",
|
|
117
|
+
existingWorker: existing?.worker_id ?? "unknown",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Transition a `claimed` dispatch into `running`. */
|
|
123
|
+
export function markRunning(dispatchId) {
|
|
124
|
+
if (!isDbAvailable())
|
|
125
|
+
return;
|
|
126
|
+
const db = _getAdapter();
|
|
127
|
+
db.prepare(`UPDATE unit_dispatches SET status = 'running'
|
|
128
|
+
WHERE id = :id AND status = 'claimed'`).run({ ":id": dispatchId });
|
|
129
|
+
}
|
|
130
|
+
/** Transition a dispatch into `completed`. */
|
|
131
|
+
export function markCompleted(dispatchId, opts) {
|
|
132
|
+
if (!isDbAvailable())
|
|
133
|
+
return;
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const db = _getAdapter();
|
|
136
|
+
let changes = 0;
|
|
137
|
+
transaction(() => {
|
|
138
|
+
const result = db.prepare(`UPDATE unit_dispatches
|
|
139
|
+
SET status = 'completed', ended_at = :ended_at,
|
|
140
|
+
exit_reason = :exit_reason,
|
|
141
|
+
verification_evidence_id = :evidence_id
|
|
142
|
+
WHERE id = :id
|
|
143
|
+
AND status IN ('claimed','running')`).run({
|
|
144
|
+
":id": dispatchId,
|
|
145
|
+
":ended_at": now,
|
|
146
|
+
":exit_reason": opts?.exitReason ?? null,
|
|
147
|
+
":evidence_id": opts?.verificationEvidenceId ?? null,
|
|
148
|
+
});
|
|
149
|
+
changes =
|
|
150
|
+
typeof result.changes === "number"
|
|
151
|
+
? result.changes
|
|
152
|
+
: 0;
|
|
153
|
+
});
|
|
154
|
+
if (changes < 1)
|
|
155
|
+
return;
|
|
156
|
+
insertAuditEvent({
|
|
157
|
+
eventId: randomUUID(),
|
|
158
|
+
traceId: dispatchId.toString(),
|
|
159
|
+
category: "orchestration",
|
|
160
|
+
type: "dispatch-completed",
|
|
161
|
+
ts: now,
|
|
162
|
+
payload: { dispatchId },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Transition a dispatch into `failed`, optionally scheduling a retry. */
|
|
166
|
+
export function markFailed(dispatchId, opts) {
|
|
167
|
+
if (!isDbAvailable())
|
|
168
|
+
return;
|
|
169
|
+
const now = new Date();
|
|
170
|
+
const nowIso = now.toISOString();
|
|
171
|
+
const nextRunIso = opts.retryAfterMs
|
|
172
|
+
? new Date(now.getTime() + opts.retryAfterMs).toISOString()
|
|
173
|
+
: null;
|
|
174
|
+
const db = _getAdapter();
|
|
175
|
+
let changes = 0;
|
|
176
|
+
transaction(() => {
|
|
177
|
+
const result = db.prepare(`UPDATE unit_dispatches
|
|
178
|
+
SET status = 'failed', ended_at = :ended_at,
|
|
179
|
+
error_summary = :error_summary,
|
|
180
|
+
last_error_code = :last_error_code,
|
|
181
|
+
last_error_at = :last_error_at,
|
|
182
|
+
retry_after_ms = :retry_after_ms,
|
|
183
|
+
next_run_at = :next_run_at
|
|
184
|
+
WHERE id = :id
|
|
185
|
+
AND status IN ('claimed','running')`).run({
|
|
186
|
+
":id": dispatchId,
|
|
187
|
+
":ended_at": nowIso,
|
|
188
|
+
":error_summary": opts.errorSummary,
|
|
189
|
+
":last_error_code": opts.errorCode ?? null,
|
|
190
|
+
":last_error_at": nowIso,
|
|
191
|
+
":retry_after_ms": opts.retryAfterMs ?? null,
|
|
192
|
+
":next_run_at": nextRunIso,
|
|
193
|
+
});
|
|
194
|
+
changes =
|
|
195
|
+
typeof result.changes === "number"
|
|
196
|
+
? result.changes
|
|
197
|
+
: 0;
|
|
198
|
+
});
|
|
199
|
+
if (changes < 1)
|
|
200
|
+
return;
|
|
201
|
+
insertAuditEvent({
|
|
202
|
+
eventId: randomUUID(),
|
|
203
|
+
traceId: dispatchId.toString(),
|
|
204
|
+
category: "orchestration",
|
|
205
|
+
type: "dispatch-failed",
|
|
206
|
+
ts: nowIso,
|
|
207
|
+
payload: { dispatchId, errorSummary: opts.errorSummary, retryAfterMs: opts.retryAfterMs ?? null },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** Transition a dispatch into `stuck`. */
|
|
211
|
+
export function markStuck(dispatchId, reason) {
|
|
212
|
+
if (!isDbAvailable())
|
|
213
|
+
return;
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
const db = _getAdapter();
|
|
216
|
+
const result = transaction(() => {
|
|
217
|
+
return db.prepare(`UPDATE unit_dispatches
|
|
218
|
+
SET status = 'stuck', ended_at = :ended_at, exit_reason = :reason
|
|
219
|
+
WHERE id = :id
|
|
220
|
+
AND status IN ('claimed','running')`).run({ ":id": dispatchId, ":ended_at": now, ":reason": reason });
|
|
221
|
+
});
|
|
222
|
+
const changes = typeof result.changes === "number"
|
|
223
|
+
? result.changes
|
|
224
|
+
: 0;
|
|
225
|
+
if (changes <= 0)
|
|
226
|
+
return;
|
|
227
|
+
insertAuditEvent({
|
|
228
|
+
eventId: randomUUID(),
|
|
229
|
+
traceId: dispatchId.toString(),
|
|
230
|
+
category: "orchestration",
|
|
231
|
+
type: "dispatch-stuck",
|
|
232
|
+
ts: now,
|
|
233
|
+
payload: { dispatchId, reason },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** Transition a dispatch into `paused`. */
|
|
237
|
+
export function markPaused(dispatchId) {
|
|
238
|
+
if (!isDbAvailable())
|
|
239
|
+
return;
|
|
240
|
+
const now = new Date().toISOString();
|
|
241
|
+
const db = _getAdapter();
|
|
242
|
+
db.prepare(`UPDATE unit_dispatches
|
|
243
|
+
SET status = 'paused', ended_at = :ended_at
|
|
244
|
+
WHERE id = :id AND status IN ('claimed','running')`).run({ ":id": dispatchId, ":ended_at": now });
|
|
245
|
+
}
|
|
246
|
+
/** Transition a dispatch into `canceled`. */
|
|
247
|
+
export function markCanceled(dispatchId, reason) {
|
|
248
|
+
if (!isDbAvailable())
|
|
249
|
+
return;
|
|
250
|
+
const now = new Date().toISOString();
|
|
251
|
+
const db = _getAdapter();
|
|
252
|
+
db.prepare(`UPDATE unit_dispatches
|
|
253
|
+
SET status = 'canceled', ended_at = :ended_at, exit_reason = :reason
|
|
254
|
+
WHERE id = :id AND status IN ('pending','claimed','running')`).run({ ":id": dispatchId, ":ended_at": now, ":reason": reason });
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Fetch the most recent N dispatches for a unit. Used by recordDispatchClaim
|
|
258
|
+
* callers to compute attempt_n and by detect-stuck.ts (B3) to consult
|
|
259
|
+
* retry budget before tripping the stuck verdict.
|
|
260
|
+
*/
|
|
261
|
+
export function getRecentForUnit(unitId, limit = 10) {
|
|
262
|
+
if (!isDbAvailable())
|
|
263
|
+
return [];
|
|
264
|
+
const db = _getAdapter();
|
|
265
|
+
return db.prepare(`SELECT * FROM unit_dispatches WHERE unit_id = :unit_id ORDER BY id DESC LIMIT :limit`).all({ ":unit_id": unitId, ":limit": limit });
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Fetch the latest dispatch for a unit, regardless of status. Returns null
|
|
269
|
+
* if the unit has never been dispatched.
|
|
270
|
+
*/
|
|
271
|
+
export function getLatestForUnit(unitId) {
|
|
272
|
+
if (!isDbAvailable())
|
|
273
|
+
return null;
|
|
274
|
+
const db = _getAdapter();
|
|
275
|
+
const row = db.prepare(`SELECT * FROM unit_dispatches WHERE unit_id = :unit_id ORDER BY id DESC LIMIT 1`).get({ ":unit_id": unitId });
|
|
276
|
+
return row ?? null;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Phase C — return the most recent unit_id values for a worker, oldest-first.
|
|
280
|
+
*
|
|
281
|
+
* Drop-in replacement for the persistence side of stuck-state.json's
|
|
282
|
+
* `recentUnits` field. The auto-loop uses this to seed loopState.recentUnits
|
|
283
|
+
* on session start so the stuck-detector window survives a session restart
|
|
284
|
+
* (#3704). Returned in oldest-first order to match the in-memory window
|
|
285
|
+
* shape that detect-stuck.ts expects.
|
|
286
|
+
*/
|
|
287
|
+
export function getRecentUnitKeysForWorker(workerId, limit = 20) {
|
|
288
|
+
if (!isDbAvailable())
|
|
289
|
+
return [];
|
|
290
|
+
const db = _getAdapter();
|
|
291
|
+
const rows = db.prepare(`SELECT unit_id FROM unit_dispatches
|
|
292
|
+
WHERE worker_id = :worker_id
|
|
293
|
+
ORDER BY started_at DESC, id DESC
|
|
294
|
+
LIMIT :limit`).all({ ":worker_id": workerId, ":limit": limit });
|
|
295
|
+
// Reverse so callers consume oldest-first (sliding-window semantics).
|
|
296
|
+
return rows.reverse().map((r) => ({ key: r.unit_id }));
|
|
297
|
+
}
|
|
298
|
+
export function getRecentUnitKeysForProjectRoot(projectRootRealpath, limit = 20) {
|
|
299
|
+
if (!isDbAvailable())
|
|
300
|
+
return [];
|
|
301
|
+
const db = _getAdapter();
|
|
302
|
+
const rows = db.prepare(`SELECT ud.unit_id
|
|
303
|
+
FROM unit_dispatches ud
|
|
304
|
+
INNER JOIN workers w ON w.worker_id = ud.worker_id
|
|
305
|
+
WHERE w.project_root_realpath = :project_root_realpath
|
|
306
|
+
ORDER BY ud.started_at DESC, ud.id DESC
|
|
307
|
+
LIMIT :limit`).all({
|
|
308
|
+
":project_root_realpath": projectRootRealpath,
|
|
309
|
+
":limit": limit,
|
|
310
|
+
});
|
|
311
|
+
return rows.reverse().map((r) => ({ key: r.unit_id }));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Fetch dispatches for a milestone filtered by status. Useful for janitors
|
|
315
|
+
* + dashboards.
|
|
316
|
+
*/
|
|
317
|
+
export function getDispatchesByStatus(milestoneId, status) {
|
|
318
|
+
if (!isDbAvailable())
|
|
319
|
+
return [];
|
|
320
|
+
const db = _getAdapter();
|
|
321
|
+
return db.prepare(`SELECT * FROM unit_dispatches WHERE milestone_id = :mid AND status = :status ORDER BY id`).all({ ":mid": milestoneId, ":status": status });
|
|
322
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Critical invariant: generated markdown must round-trip through
|
|
9
9
|
// parseDecisionsTable() and parseRequirementsSections() with field fidelity.
|
|
10
|
-
import { resolve } from 'node:path';
|
|
10
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
11
11
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
12
12
|
import { resolveGsdRootFile } from './paths.js';
|
|
13
13
|
import { saveFile } from './files.js';
|
|
@@ -16,6 +16,7 @@ import { logWarning, logError } from './workflow-logger.js';
|
|
|
16
16
|
import { invalidateStateCache } from './state.js';
|
|
17
17
|
import { clearPathCache } from './paths.js';
|
|
18
18
|
import { clearParseCache } from './files.js';
|
|
19
|
+
import { createWorkspace, scopeMilestone } from './workspace.js';
|
|
19
20
|
// ─── Freeform Detection ───────────────────────────────────────────────────
|
|
20
21
|
/**
|
|
21
22
|
* Detect whether a DECISIONS.md file is in canonical table format
|
|
@@ -614,40 +615,102 @@ export async function updateRequirementInDb(id, updates, basePath) {
|
|
|
614
615
|
}
|
|
615
616
|
}
|
|
616
617
|
/**
|
|
617
|
-
* Save
|
|
618
|
+
* Save a root-level artifact (no milestone) to DB and write to disk,
|
|
619
|
+
* routing path construction through workspace.contract.projectGsd directly.
|
|
620
|
+
* Use this instead of saveArtifactToDbByScope when milestone_id is absent.
|
|
621
|
+
*/
|
|
622
|
+
export async function saveArtifactToDbForWorkspace(workspace, opts) {
|
|
623
|
+
try {
|
|
624
|
+
const db = await import('./gsd-db.js');
|
|
625
|
+
const gsdDir = workspace.contract.projectGsd;
|
|
626
|
+
const fullPath = resolve(gsdDir, opts.path);
|
|
627
|
+
const rel0 = relative(gsdDir, fullPath);
|
|
628
|
+
if (rel0.startsWith('..') || isAbsolute(rel0)) {
|
|
629
|
+
throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbForWorkspace: path escapes .gsd/ directory: ${opts.path}`);
|
|
630
|
+
}
|
|
631
|
+
let contentToPersist = opts.content;
|
|
632
|
+
if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
|
|
633
|
+
const activeRequirements = db.getActiveRequirements();
|
|
634
|
+
if (activeRequirements.length === 0) {
|
|
635
|
+
throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbForWorkspace: REQUIREMENTS final save requires active DB-backed requirements');
|
|
636
|
+
}
|
|
637
|
+
contentToPersist = generateRequirementsMd(activeRequirements);
|
|
638
|
+
}
|
|
639
|
+
let skipDiskWrite = false;
|
|
640
|
+
if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
|
|
641
|
+
const existingSize = statSync(fullPath).size;
|
|
642
|
+
const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
|
|
643
|
+
if (existingSize > 0 && newSize < existingSize * 0.5) {
|
|
644
|
+
logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: 'saveArtifactToDbForWorkspace', path: opts.path });
|
|
645
|
+
skipDiskWrite = true;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
db.insertArtifact({
|
|
649
|
+
path: opts.path,
|
|
650
|
+
artifact_type: opts.artifact_type,
|
|
651
|
+
milestone_id: null,
|
|
652
|
+
slice_id: null,
|
|
653
|
+
task_id: null,
|
|
654
|
+
full_content: contentToPersist,
|
|
655
|
+
});
|
|
656
|
+
if (!skipDiskWrite) {
|
|
657
|
+
try {
|
|
658
|
+
await saveFile(fullPath, contentToPersist);
|
|
659
|
+
}
|
|
660
|
+
catch (diskErr) {
|
|
661
|
+
logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbForWorkspace', path: opts.path, error: String(diskErr.message) });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
invalidateStateCache();
|
|
665
|
+
clearPathCache();
|
|
666
|
+
clearParseCache();
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
logError('manifest', 'saveArtifactToDbForWorkspace failed', { fn: 'saveArtifactToDbForWorkspace', error: String(err.message) });
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Save an artifact to DB and write the corresponding markdown file to disk,
|
|
675
|
+
* routing all path construction through the workspace contract.
|
|
676
|
+
*
|
|
618
677
|
* The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
|
|
619
|
-
* The full file path is computed as
|
|
678
|
+
* The full file path is computed as scope.workspace.contract.projectGsd + '/' + path.
|
|
620
679
|
*/
|
|
621
|
-
export async function
|
|
680
|
+
export async function saveArtifactToDbByScope(scope, opts) {
|
|
681
|
+
// Guard: an empty milestoneId produces malformed paths (milestoneDir = join(gsd, "milestones", "")).
|
|
682
|
+
// Callers that have no milestone should use saveArtifactToDbForWorkspace instead.
|
|
683
|
+
if (!scope.milestoneId) {
|
|
684
|
+
throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: milestoneId is empty — use saveArtifactToDbForWorkspace for root artifacts`);
|
|
685
|
+
}
|
|
622
686
|
try {
|
|
623
687
|
const db = await import('./gsd-db.js');
|
|
688
|
+
// Use contract.projectGsd as the canonical .gsd directory — never a hand-rolled basePath join.
|
|
689
|
+
const gsdDir = scope.workspace.contract.projectGsd;
|
|
690
|
+
const fullPath = resolve(gsdDir, opts.path);
|
|
624
691
|
// Guard against path traversal before any reads/writes
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`);
|
|
692
|
+
const rel1 = relative(gsdDir, fullPath);
|
|
693
|
+
if (rel1.startsWith('..') || isAbsolute(rel1)) {
|
|
694
|
+
throw new GSDError(GSD_IO_ERROR, `saveArtifactToDbByScope: path escapes .gsd/ directory: ${opts.path}`);
|
|
629
695
|
}
|
|
630
696
|
let contentToPersist = opts.content;
|
|
631
697
|
if (opts.artifact_type === 'REQUIREMENTS' && opts.path === 'REQUIREMENTS.md') {
|
|
632
698
|
const activeRequirements = db.getActiveRequirements();
|
|
633
699
|
if (activeRequirements.length === 0) {
|
|
634
|
-
throw new GSDError(GSD_STALE_STATE, '
|
|
700
|
+
throw new GSDError(GSD_STALE_STATE, 'saveArtifactToDbByScope: REQUIREMENTS final save requires active DB-backed requirements');
|
|
635
701
|
}
|
|
636
702
|
contentToPersist = generateRequirementsMd(activeRequirements);
|
|
637
703
|
}
|
|
638
704
|
// Shrinkage guard: if the projection file already exists and the new
|
|
639
705
|
// content is significantly smaller (<50%), preserve the richer file on
|
|
640
706
|
// disk, but keep the DB row authoritative with the caller-provided content.
|
|
641
|
-
//
|
|
642
|
-
// Root canonical artifacts are exempt because their content is rendered
|
|
643
|
-
// from canonical DB state, and cleanup/consolidation is often intentionally
|
|
644
|
-
// much smaller than a malformed accumulated file.
|
|
707
|
+
// Root canonical artifacts are exempt (rendered from canonical DB state).
|
|
645
708
|
let skipDiskWrite = false;
|
|
646
709
|
if (!isRootCanonicalArtifact(opts) && existsSync(fullPath)) {
|
|
647
710
|
const existingSize = statSync(fullPath).size;
|
|
648
711
|
const newSize = Buffer.byteLength(contentToPersist, 'utf-8');
|
|
649
712
|
if (existingSize > 0 && newSize < existingSize * 0.5) {
|
|
650
|
-
logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: '
|
|
713
|
+
logWarning('projection', `new content (${newSize}B) is <50% of existing projection (${existingSize}B), preserving disk file while DB remains authoritative`, { fn: 'saveArtifactToDbByScope', path: opts.path });
|
|
651
714
|
skipDiskWrite = true;
|
|
652
715
|
}
|
|
653
716
|
}
|
|
@@ -665,7 +728,7 @@ export async function saveArtifactToDb(opts, basePath) {
|
|
|
665
728
|
await saveFile(fullPath, contentToPersist);
|
|
666
729
|
}
|
|
667
730
|
catch (diskErr) {
|
|
668
|
-
logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: '
|
|
731
|
+
logWarning('projection', 'artifact projection write failed; DB artifact remains committed', { fn: 'saveArtifactToDbByScope', path: opts.path, error: String(diskErr.message) });
|
|
669
732
|
}
|
|
670
733
|
}
|
|
671
734
|
// Invalidate file-read caches so deriveState() sees the updated markdown.
|
|
@@ -675,7 +738,24 @@ export async function saveArtifactToDb(opts, basePath) {
|
|
|
675
738
|
clearParseCache();
|
|
676
739
|
}
|
|
677
740
|
catch (err) {
|
|
678
|
-
logError('manifest', '
|
|
741
|
+
logError('manifest', 'saveArtifactToDbByScope failed', { fn: 'saveArtifactToDbByScope', error: String(err.message) });
|
|
679
742
|
throw err;
|
|
680
743
|
}
|
|
681
744
|
}
|
|
745
|
+
/**
|
|
746
|
+
* Save an artifact to DB and write the corresponding markdown file to disk.
|
|
747
|
+
* The path is relative to .gsd/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
|
|
748
|
+
* The full file path is computed as basePath + '.gsd/' + path.
|
|
749
|
+
*
|
|
750
|
+
* @deprecated Use saveArtifactToDbByScope instead, which routes through the
|
|
751
|
+
* workspace contract for canonical path resolution.
|
|
752
|
+
* TODO(C-future): remove this legacy wrapper once all callers are migrated.
|
|
753
|
+
*/
|
|
754
|
+
export async function saveArtifactToDb(opts, basePath) {
|
|
755
|
+
const workspace = createWorkspace(basePath);
|
|
756
|
+
const milestoneId = opts.milestone_id;
|
|
757
|
+
if (milestoneId) {
|
|
758
|
+
return saveArtifactToDbByScope(scopeMilestone(workspace, milestoneId), opts);
|
|
759
|
+
}
|
|
760
|
+
return saveArtifactToDbForWorkspace(workspace, opts);
|
|
761
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Delegation policy — codifies which GSD MCP tools are safe to run as
|
|
2
|
+
// background sub-agents while the foreground /gsd flow continues. Verdicts
|
|
3
|
+
// are derived from the round-1 and round-2 evaluations recorded in this
|
|
4
|
+
// branch's PR description; the rationale field on each entry preserves
|
|
5
|
+
// the reason so future changes have to revisit the analysis explicitly.
|
|
6
|
+
//
|
|
7
|
+
// Default-deny: unknown tools are never backgroundable.
|
|
8
|
+
//
|
|
9
|
+
// ─── Tool-name vs unit-type namespaces ───────────────────────────────────
|
|
10
|
+
// Entries are keyed by canonical MCP tool name (`gsd_*`). The optional
|
|
11
|
+
// `unitType` field is a *secondary* index for the dispatcher's convenience
|
|
12
|
+
// — it bridges this policy to `auto-dispatch.ts`' `DispatchAction.unitType`
|
|
13
|
+
// values. The two namespaces are not 1:1:
|
|
14
|
+
//
|
|
15
|
+
// - Some tools have no corresponding unit type (e.g. `gsd_doctor`,
|
|
16
|
+
// `gsd_plan_task`) and intentionally omit `unitType`.
|
|
17
|
+
// - Some unit types share a tool — e.g. `execute-task`, `execute-task-simple`,
|
|
18
|
+
// and `reactive-execute` all invoke `gsd_execute`. The current shape
|
|
19
|
+
// allows only one `unitType` per entry, so those units fall through to
|
|
20
|
+
// `getVerdictByUnitType() === null` (→ `backgroundable: false`) even
|
|
21
|
+
// though `gsd_execute` itself is GOOD. This is the intended default-deny
|
|
22
|
+
// posture until a future PR wires actual background dispatch and
|
|
23
|
+
// decides whether each unit-level orchestration is safe — the unit
|
|
24
|
+
// wraps a prompt, harness setup, and post-processing on top of the
|
|
25
|
+
// tool, and the tool's safety doesn't transfer automatically.
|
|
26
|
+
//
|
|
27
|
+
// Auto-dispatch produces 20 distinct unit types; only 5 are explicitly
|
|
28
|
+
// classified here. The other 15 default-deny:
|
|
29
|
+
// complete-milestone, complete-slice, discuss-milestone, discuss-project,
|
|
30
|
+
// discuss-requirements, execute-task, execute-task-simple, gate-evaluate,
|
|
31
|
+
// reactive-execute, refine-slice, research-decision, research-milestone,
|
|
32
|
+
// research-project, research-slice, rewrite-docs, run-uat
|
|
33
|
+
//
|
|
34
|
+
// Adding a `unitType` mapping (or a future `unitTypes: string[]`) to an
|
|
35
|
+
// existing entry is the place to lift any of these out of default-deny
|
|
36
|
+
// when the analysis has been done.
|
|
37
|
+
const POLICY = {
|
|
38
|
+
gsd_plan_slice: {
|
|
39
|
+
toolName: "gsd_plan_slice",
|
|
40
|
+
unitType: "plan-slice",
|
|
41
|
+
verdict: "good",
|
|
42
|
+
rationale: "Self-contained, no user prompts, atomic DB tx; existing slice-parallel-orchestrator pattern transfers cleanly.",
|
|
43
|
+
constraints: [
|
|
44
|
+
"Lock the slice from further user discussion once dispatched (context is frozen at dispatch time).",
|
|
45
|
+
"Foreground must not derive state for that slice while the transaction is in flight.",
|
|
46
|
+
"Foreground must await background completion before any tool reads the planned tasks/gates.",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
gsd_execute: {
|
|
50
|
+
toolName: "gsd_execute",
|
|
51
|
+
// No `unitType` set on purpose — the underlying tool is safe, but the
|
|
52
|
+
// unit-level orchestrations that invoke it (`execute-task`,
|
|
53
|
+
// `execute-task-simple`, `reactive-execute`) wrap additional prompt and
|
|
54
|
+
// harness work whose safety is a separate analysis. Default-deny those
|
|
55
|
+
// units until that analysis is recorded; adding `unitType` here would
|
|
56
|
+
// promote them silently.
|
|
57
|
+
verdict: "good",
|
|
58
|
+
rationale: "No DB writes; UUID-isolated stdout/stderr/meta files; existing reactive-execute parallel-subagent precedent.",
|
|
59
|
+
},
|
|
60
|
+
gsd_validate_milestone: {
|
|
61
|
+
toolName: "gsd_validate_milestone",
|
|
62
|
+
unitType: "validate-milestone",
|
|
63
|
+
verdict: "good",
|
|
64
|
+
rationale: "Verdict pre-computed by parallel reviewers; atomic DB tx plus isolated VALIDATION.md write; no user interaction.",
|
|
65
|
+
},
|
|
66
|
+
gsd_reassess_roadmap: {
|
|
67
|
+
toolName: "gsd_reassess_roadmap",
|
|
68
|
+
unitType: "reassess-roadmap",
|
|
69
|
+
verdict: "good",
|
|
70
|
+
rationale: "Narrower mutation scope than plan_milestone; structural guards prevent modification of completed slices.",
|
|
71
|
+
},
|
|
72
|
+
gsd_doctor: {
|
|
73
|
+
toolName: "gsd_doctor",
|
|
74
|
+
verdict: "risky",
|
|
75
|
+
rationale: "Diagnostic-only mode (fix=false) is safe to background; fix=true writes STATE.md/ROADMAP.md without session-lock coordination and can race the foreground flow.",
|
|
76
|
+
constraints: [
|
|
77
|
+
"Background only with fix=false (diagnostic-only).",
|
|
78
|
+
"Apply fixes synchronously, only when no foreground unit is dispatched.",
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
gsd_plan_milestone: {
|
|
82
|
+
toolName: "gsd_plan_milestone",
|
|
83
|
+
unitType: "plan-milestone",
|
|
84
|
+
verdict: "risky",
|
|
85
|
+
rationale: "Inputs require CONTEXT.md from discuss-milestone, so initial questioning is already done by the time it can start; TOCTOU guards and projection coherence make concurrency unsafe.",
|
|
86
|
+
},
|
|
87
|
+
gsd_replan_slice: {
|
|
88
|
+
toolName: "gsd_replan_slice",
|
|
89
|
+
unitType: "replan-slice",
|
|
90
|
+
verdict: "risky",
|
|
91
|
+
rationale: "Blocks the replanning→executing state transition on a gate that waits for S##-REPLAN.md; background failure leaves the flow stuck.",
|
|
92
|
+
},
|
|
93
|
+
gsd_plan_task: {
|
|
94
|
+
toolName: "gsd_plan_task",
|
|
95
|
+
verdict: "no",
|
|
96
|
+
rationale: "plan-slice prompt explicitly forbids calling gsd_plan_task separately; per-task granularity multiplies manifest writes and projection re-renders with no payoff.",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
// Alias map keyed on the secondary name; resolves to the canonical entry above.
|
|
100
|
+
// Sourced from packages/mcp-server/src/workflow-tools.ts alias registrations
|
|
101
|
+
// (gsd_milestone_validate, gsd_roadmap_reassess, gsd_slice_replan, gsd_task_plan).
|
|
102
|
+
const ALIASES = {
|
|
103
|
+
gsd_milestone_validate: "gsd_validate_milestone",
|
|
104
|
+
gsd_roadmap_reassess: "gsd_reassess_roadmap",
|
|
105
|
+
gsd_slice_replan: "gsd_replan_slice",
|
|
106
|
+
gsd_task_plan: "gsd_plan_task",
|
|
107
|
+
};
|
|
108
|
+
function resolveCanonical(name) {
|
|
109
|
+
return ALIASES[name] ?? name;
|
|
110
|
+
}
|
|
111
|
+
export function getDelegationVerdict(toolName) {
|
|
112
|
+
return POLICY[resolveCanonical(toolName)] ?? null;
|
|
113
|
+
}
|
|
114
|
+
export function isBackgroundable(toolName) {
|
|
115
|
+
const entry = getDelegationVerdict(toolName);
|
|
116
|
+
return entry?.verdict === "good";
|
|
117
|
+
}
|
|
118
|
+
export function listBackgroundableTools() {
|
|
119
|
+
return Object.values(POLICY)
|
|
120
|
+
.filter((entry) => entry.verdict === "good")
|
|
121
|
+
.map((entry) => entry.toolName)
|
|
122
|
+
.sort();
|
|
123
|
+
}
|
|
124
|
+
export function getVerdictByUnitType(unitType) {
|
|
125
|
+
for (const entry of Object.values(POLICY)) {
|
|
126
|
+
if (entry.unitType === unitType)
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Annotates a dispatch action in place with `backgroundable: true` when its
|
|
133
|
+
* unitType has a `good` verdict in the policy. Stop/skip actions pass through
|
|
134
|
+
* unchanged. Default-deny: unknown unit types resolve to `false`.
|
|
135
|
+
*
|
|
136
|
+
* **Mutation contract.** The `backgroundable` field is written directly onto
|
|
137
|
+
* the passed action object. This is intentional — every dispatch path in
|
|
138
|
+
* `auto-dispatch.ts` constructs a fresh action object per `where(ctx)` /
|
|
139
|
+
* `evaluateDispatch(ctx)` invocation, so in-place mutation cannot leak across
|
|
140
|
+
* dispatch cycles. Future dispatch rules MUST follow that convention: never
|
|
141
|
+
* cache or share `DispatchAction` objects across calls. If you need to cache,
|
|
142
|
+
* either freeze the cached object (`Object.freeze`) and clone on read, or
|
|
143
|
+
* stop calling `annotateBackgroundable` on the shared instance. The annotator
|
|
144
|
+
* always recomputes from the policy on every call (no internal cache), so
|
|
145
|
+
* repeated invocations on the same object will overwrite stale values
|
|
146
|
+
* deterministically — see the `annotateBackgroundable recomputes on each call`
|
|
147
|
+
* test for the contract pin.
|
|
148
|
+
*/
|
|
149
|
+
export function annotateBackgroundable(action) {
|
|
150
|
+
if (action.action !== "dispatch")
|
|
151
|
+
return action;
|
|
152
|
+
const verdict = getVerdictByUnitType(action.unitType);
|
|
153
|
+
action.backgroundable = verdict?.verdict === "good";
|
|
154
|
+
return action;
|
|
155
|
+
}
|