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,378 @@
|
|
|
1
|
+
// GSD-2 + metrics-scope.test.ts: tests for scope-aware metrics variants (C6)
|
|
2
|
+
|
|
3
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import {
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
initMetrics,
|
|
19
|
+
resetMetrics,
|
|
20
|
+
getLedger,
|
|
21
|
+
snapshotUnitMetrics,
|
|
22
|
+
initMetricsByScope,
|
|
23
|
+
getLedgerByScope,
|
|
24
|
+
resetMetricsByScope,
|
|
25
|
+
snapshotUnitMetricsByScope,
|
|
26
|
+
type MetricsLedger,
|
|
27
|
+
type UnitMetrics,
|
|
28
|
+
} from "../metrics.js";
|
|
29
|
+
import { createWorkspace, scopeMilestone } from "../workspace.js";
|
|
30
|
+
|
|
31
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeProjectDir(): string {
|
|
34
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-metrics-scope-")));
|
|
35
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mockCtx(messages: any[] = []): any {
|
|
40
|
+
const entries = messages.map((msg, i) => ({
|
|
41
|
+
type: "message",
|
|
42
|
+
id: `entry-${i}`,
|
|
43
|
+
parentId: i > 0 ? `entry-${i - 1}` : null,
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
message: msg,
|
|
46
|
+
}));
|
|
47
|
+
return {
|
|
48
|
+
sessionManager: { getEntries: () => entries },
|
|
49
|
+
model: { id: "test-model" },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assistantMsg(input = 1000, output = 500): any {
|
|
54
|
+
return {
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: [{ type: "text", text: "done" }],
|
|
57
|
+
usage: {
|
|
58
|
+
input,
|
|
59
|
+
output,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
totalTokens: input + output,
|
|
63
|
+
cost: { total: 0.01 },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("ByScope variant writes to the same path as legacy variant", () => {
|
|
71
|
+
let projectDir: string;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
projectDir = makeProjectDir();
|
|
75
|
+
resetMetrics();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
resetMetrics();
|
|
80
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("metrics.json written by snapshotUnitMetrics matches path used by snapshotUnitMetricsByScope", () => {
|
|
84
|
+
const ws = createWorkspace(projectDir);
|
|
85
|
+
const scope = scopeMilestone(ws, "M001");
|
|
86
|
+
|
|
87
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
88
|
+
const startedAt = Date.now() - 5000;
|
|
89
|
+
|
|
90
|
+
// Write via legacy path
|
|
91
|
+
initMetrics(projectDir);
|
|
92
|
+
snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
93
|
+
resetMetrics();
|
|
94
|
+
|
|
95
|
+
// Read via scope path
|
|
96
|
+
initMetricsByScope(scope);
|
|
97
|
+
const scopedLedger = getLedgerByScope(scope);
|
|
98
|
+
assert.ok(scopedLedger, "scoped ledger should load the same metrics.json");
|
|
99
|
+
assert.equal(scopedLedger!.units.length, 1, "should see the unit written by legacy path");
|
|
100
|
+
assert.equal(scopedLedger!.units[0].id, "M001/S01/T01");
|
|
101
|
+
resetMetricsByScope(scope);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("snapshotUnitMetricsByScope writes to the same metrics.json as the legacy path", () => {
|
|
105
|
+
const ws = createWorkspace(projectDir);
|
|
106
|
+
const scope = scopeMilestone(ws, "M001");
|
|
107
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
108
|
+
const startedAt = Date.now() - 5000;
|
|
109
|
+
|
|
110
|
+
// Write via scope path (no initMetrics called)
|
|
111
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
112
|
+
resetMetricsByScope(scope);
|
|
113
|
+
|
|
114
|
+
// Read via legacy path
|
|
115
|
+
initMetrics(projectDir);
|
|
116
|
+
const legacyLedger = getLedger();
|
|
117
|
+
assert.ok(legacyLedger, "legacy path should read what the scope variant wrote");
|
|
118
|
+
assert.equal(legacyLedger!.units.length, 1);
|
|
119
|
+
assert.equal(legacyLedger!.units[0].id, "M001/S01/T01");
|
|
120
|
+
resetMetrics();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("ByScope variant is pinned to scope — cwd-drift does not move write target", () => {
|
|
125
|
+
let projectDir: string;
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
projectDir = makeProjectDir();
|
|
129
|
+
resetMetrics();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
resetMetrics();
|
|
134
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("write target is the scope's projectRoot regardless of process.cwd()", () => {
|
|
138
|
+
const ws = createWorkspace(projectDir);
|
|
139
|
+
const scope = scopeMilestone(ws, "M001");
|
|
140
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
141
|
+
const startedAt = Date.now() - 3000;
|
|
142
|
+
|
|
143
|
+
// Record projectRoot before writing
|
|
144
|
+
const expectedMetricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
145
|
+
|
|
146
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
147
|
+
|
|
148
|
+
// Verify the file was written to the expected location
|
|
149
|
+
const raw = readFileSync(expectedMetricsPath, "utf-8");
|
|
150
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
151
|
+
assert.equal(parsed.units.length, 1);
|
|
152
|
+
assert.equal(parsed.units[0].id, "M001/S01/T01");
|
|
153
|
+
|
|
154
|
+
resetMetricsByScope(scope);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("two scopes for different projectRoots write to separate metrics.json files", () => {
|
|
158
|
+
const projectDir2 = makeProjectDir();
|
|
159
|
+
try {
|
|
160
|
+
const ws1 = createWorkspace(projectDir);
|
|
161
|
+
const ws2 = createWorkspace(projectDir2);
|
|
162
|
+
const scope1 = scopeMilestone(ws1, "M001");
|
|
163
|
+
const scope2 = scopeMilestone(ws2, "M002");
|
|
164
|
+
|
|
165
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
166
|
+
const startedAt = Date.now() - 3000;
|
|
167
|
+
|
|
168
|
+
snapshotUnitMetricsByScope(scope1, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
169
|
+
snapshotUnitMetricsByScope(scope2, ctx, "execute-task", "M002/S01/T01", startedAt, "test-model");
|
|
170
|
+
|
|
171
|
+
const metrics1 = JSON.parse(
|
|
172
|
+
readFileSync(join(ws1.projectRoot, ".gsd", "metrics.json"), "utf-8"),
|
|
173
|
+
) as MetricsLedger;
|
|
174
|
+
const metrics2 = JSON.parse(
|
|
175
|
+
readFileSync(join(ws2.projectRoot, ".gsd", "metrics.json"), "utf-8"),
|
|
176
|
+
) as MetricsLedger;
|
|
177
|
+
|
|
178
|
+
assert.equal(metrics1.units.length, 1);
|
|
179
|
+
assert.equal(metrics1.units[0].id, "M001/S01/T01");
|
|
180
|
+
assert.equal(metrics2.units.length, 1);
|
|
181
|
+
assert.equal(metrics2.units[0].id, "M002/S01/T01");
|
|
182
|
+
|
|
183
|
+
resetMetricsByScope(scope1);
|
|
184
|
+
resetMetricsByScope(scope2);
|
|
185
|
+
} finally {
|
|
186
|
+
rmSync(projectDir2, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("ByScope works without calling initMetrics", () => {
|
|
192
|
+
let projectDir: string;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
projectDir = makeProjectDir();
|
|
196
|
+
// Deliberately do NOT call initMetrics / resetMetrics
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
resetMetrics();
|
|
201
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("snapshotUnitMetricsByScope succeeds without initMetrics having been called", () => {
|
|
205
|
+
const ws = createWorkspace(projectDir);
|
|
206
|
+
const scope = scopeMilestone(ws, "M001");
|
|
207
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
208
|
+
|
|
209
|
+
// Confirm singleton was never initialized
|
|
210
|
+
assert.equal(getLedger(), null, "module singleton should be null — initMetrics was never called");
|
|
211
|
+
|
|
212
|
+
const unit = snapshotUnitMetricsByScope(
|
|
213
|
+
scope,
|
|
214
|
+
ctx,
|
|
215
|
+
"execute-task",
|
|
216
|
+
"M001/S01/T01",
|
|
217
|
+
Date.now() - 2000,
|
|
218
|
+
"test-model",
|
|
219
|
+
);
|
|
220
|
+
assert.ok(unit, "snapshotUnitMetricsByScope should return a unit");
|
|
221
|
+
assert.equal(unit!.id, "M001/S01/T01");
|
|
222
|
+
|
|
223
|
+
// Verify on disk
|
|
224
|
+
const raw = readFileSync(join(projectDir, ".gsd", "metrics.json"), "utf-8");
|
|
225
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
226
|
+
assert.equal(parsed.units.length, 1);
|
|
227
|
+
|
|
228
|
+
resetMetricsByScope(scope);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("initMetricsByScope succeeds without initMetrics having been called", () => {
|
|
232
|
+
const ws = createWorkspace(projectDir);
|
|
233
|
+
const scope = scopeMilestone(ws, "M001");
|
|
234
|
+
|
|
235
|
+
assert.equal(getLedger(), null);
|
|
236
|
+
|
|
237
|
+
initMetricsByScope(scope);
|
|
238
|
+
const l = getLedgerByScope(scope);
|
|
239
|
+
assert.ok(l, "getLedgerByScope should return a ledger after initMetricsByScope");
|
|
240
|
+
assert.equal(l!.version, 1);
|
|
241
|
+
assert.equal(l!.units.length, 0);
|
|
242
|
+
|
|
243
|
+
resetMetricsByScope(scope);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("ByScope atomic write-merge — concurrent writers do not clobber", () => {
|
|
248
|
+
let projectDir: string;
|
|
249
|
+
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
projectDir = makeProjectDir();
|
|
252
|
+
resetMetrics();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
afterEach(() => {
|
|
256
|
+
resetMetrics();
|
|
257
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Worker script: same lock+merge semantics as saveLedger, written in plain CJS
|
|
261
|
+
// so it can run as a child process without loading the full extension tree.
|
|
262
|
+
const MERGE_WORKER = `
|
|
263
|
+
const { openSync, closeSync, unlinkSync, existsSync, readFileSync, mkdirSync, renameSync } = require('node:fs');
|
|
264
|
+
const { dirname } = require('node:path');
|
|
265
|
+
const { randomBytes } = require('node:crypto');
|
|
266
|
+
|
|
267
|
+
const metricsPath = process.env.GSD_SCOPE_METRICS_PATH;
|
|
268
|
+
const milestoneId = process.env.GSD_SCOPE_MILESTONE_ID;
|
|
269
|
+
const lockPath = metricsPath + '.lock';
|
|
270
|
+
|
|
271
|
+
function acquireLock(lp, ms) {
|
|
272
|
+
const deadline = Date.now() + ms;
|
|
273
|
+
while (Date.now() < deadline) {
|
|
274
|
+
try { const fd = openSync(lp, 'wx'); closeSync(fd); return true; }
|
|
275
|
+
catch { const w = Date.now() + Math.min(50, deadline - Date.now()); while (Date.now() < w) {} }
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
function releaseLock(lp) { try { unlinkSync(lp); } catch {} }
|
|
280
|
+
function saveAtomic(fp, data) {
|
|
281
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
282
|
+
const tmp = fp + '.tmp.' + randomBytes(4).toString('hex');
|
|
283
|
+
require('node:fs').writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
|
|
284
|
+
renameSync(tmp, fp);
|
|
285
|
+
}
|
|
286
|
+
function dedup(units) {
|
|
287
|
+
const m = new Map();
|
|
288
|
+
for (const u of units) {
|
|
289
|
+
const k = u.type + '\\0' + u.id + '\\0' + u.startedAt;
|
|
290
|
+
const e = m.get(k);
|
|
291
|
+
if (!e || u.finishedAt > e.finishedAt) m.set(k, u);
|
|
292
|
+
}
|
|
293
|
+
return Array.from(m.values());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const unit = {
|
|
297
|
+
type: 'execute-task', id: milestoneId + '/S01/T01', model: 'test',
|
|
298
|
+
startedAt: 1000, finishedAt: Date.now(),
|
|
299
|
+
tokens: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, total: 15 },
|
|
300
|
+
cost: 0.001, toolCalls: 0, assistantMessages: 1, userMessages: 1,
|
|
301
|
+
};
|
|
302
|
+
const workerLedger = { version: 1, projectStartedAt: 1000, units: [unit] };
|
|
303
|
+
|
|
304
|
+
const acquired = acquireLock(lockPath, 5000);
|
|
305
|
+
try {
|
|
306
|
+
let diskUnits = [];
|
|
307
|
+
if (existsSync(metricsPath)) {
|
|
308
|
+
try { const p = JSON.parse(readFileSync(metricsPath, 'utf-8')); if (p && Array.isArray(p.units)) diskUnits = p.units; } catch {}
|
|
309
|
+
}
|
|
310
|
+
saveAtomic(metricsPath, { ...workerLedger, units: dedup([...diskUnits, ...workerLedger.units]) });
|
|
311
|
+
} finally {
|
|
312
|
+
if (acquired) releaseLock(lockPath);
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
|
|
316
|
+
function spawnMergeWorker(metricsPath: string, milestoneId: string): void {
|
|
317
|
+
const result = spawnSync(process.execPath, ["-e", MERGE_WORKER], {
|
|
318
|
+
env: {
|
|
319
|
+
...process.env,
|
|
320
|
+
GSD_SCOPE_METRICS_PATH: metricsPath,
|
|
321
|
+
GSD_SCOPE_MILESTONE_ID: milestoneId,
|
|
322
|
+
},
|
|
323
|
+
encoding: "utf-8",
|
|
324
|
+
timeout: 10_000,
|
|
325
|
+
});
|
|
326
|
+
if (result.error) throw result.error;
|
|
327
|
+
if (result.status !== 0) {
|
|
328
|
+
throw new Error(`Worker for ${milestoneId} failed:\n${result.stderr}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
test("snapshotUnitMetricsByScope preserves a pre-existing entry written by a concurrent worker", () => {
|
|
333
|
+
const ws = createWorkspace(projectDir);
|
|
334
|
+
const scope = scopeMilestone(ws, "M002");
|
|
335
|
+
const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
336
|
+
|
|
337
|
+
// Simulate a concurrent worker that already wrote M001's entry to disk
|
|
338
|
+
spawnMergeWorker(metricsPath, "M001");
|
|
339
|
+
|
|
340
|
+
// Now write M002 via scope variant — must preserve M001's entry
|
|
341
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
342
|
+
snapshotUnitMetricsByScope(
|
|
343
|
+
scope,
|
|
344
|
+
ctx,
|
|
345
|
+
"execute-task",
|
|
346
|
+
"M002/S01/T01",
|
|
347
|
+
Date.now() - 2000,
|
|
348
|
+
"test-model",
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
352
|
+
const parsed: MetricsLedger = JSON.parse(raw);
|
|
353
|
+
assert.equal(parsed.units.length, 2, "both M001 and M002 units must be in metrics.json");
|
|
354
|
+
|
|
355
|
+
const ids = parsed.units.map((u: UnitMetrics) => u.id);
|
|
356
|
+
assert.ok(ids.some((id) => id.startsWith("M001")), "M001 unit must be preserved");
|
|
357
|
+
assert.ok(ids.some((id) => id.startsWith("M002")), "M002 unit must be present");
|
|
358
|
+
|
|
359
|
+
resetMetricsByScope(scope);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("idempotent ByScope snapshot does not duplicate units on disk", () => {
|
|
363
|
+
const ws = createWorkspace(projectDir);
|
|
364
|
+
const scope = scopeMilestone(ws, "M001");
|
|
365
|
+
const ctx = mockCtx([assistantMsg()]);
|
|
366
|
+
const startedAt = Date.now() - 3000;
|
|
367
|
+
const metricsPath = join(ws.projectRoot, ".gsd", "metrics.json");
|
|
368
|
+
|
|
369
|
+
// Snapshot twice with same type+id+startedAt
|
|
370
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
371
|
+
snapshotUnitMetricsByScope(scope, ctx, "execute-task", "M001/S01/T01", startedAt, "test-model");
|
|
372
|
+
|
|
373
|
+
const parsed: MetricsLedger = JSON.parse(readFileSync(metricsPath, "utf-8"));
|
|
374
|
+
assert.equal(parsed.units.length, 1, "duplicate snapshots must not create duplicate entries");
|
|
375
|
+
|
|
376
|
+
resetMetricsByScope(scope);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// gsd-2 + Milestone leases tests (Phase B coordination — fencing semantics)
|
|
2
|
+
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
openDatabase,
|
|
11
|
+
closeDatabase,
|
|
12
|
+
insertMilestone,
|
|
13
|
+
_getAdapter,
|
|
14
|
+
} from "../gsd-db.ts";
|
|
15
|
+
import { registerAutoWorker } from "../db/auto-workers.ts";
|
|
16
|
+
import {
|
|
17
|
+
claimMilestoneLease,
|
|
18
|
+
releaseMilestoneLease,
|
|
19
|
+
refreshMilestoneLease,
|
|
20
|
+
getMilestoneLease,
|
|
21
|
+
} from "../db/milestone-leases.ts";
|
|
22
|
+
|
|
23
|
+
function makeBase(): string {
|
|
24
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-leases-"));
|
|
25
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
26
|
+
return base;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanup(base: string): void {
|
|
30
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
31
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("first claim returns ok=true with token=1", (t) => {
|
|
35
|
+
const base = makeBase();
|
|
36
|
+
t.after(() => cleanup(base));
|
|
37
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
38
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
39
|
+
|
|
40
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
41
|
+
const claim = claimMilestoneLease(w1, "M001");
|
|
42
|
+
assert.equal(claim.ok, true);
|
|
43
|
+
if (claim.ok) {
|
|
44
|
+
assert.equal(claim.token, 1, "fresh claim starts fencing token at 1");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const row = getMilestoneLease("M001");
|
|
48
|
+
assert.ok(row);
|
|
49
|
+
assert.equal(row!.worker_id, w1);
|
|
50
|
+
assert.equal(row!.fencing_token, 1);
|
|
51
|
+
assert.equal(row!.status, "held");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("second claim by different worker is rejected while lease is held", (t) => {
|
|
55
|
+
const base = makeBase();
|
|
56
|
+
t.after(() => cleanup(base));
|
|
57
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
58
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
59
|
+
|
|
60
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
61
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
62
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
63
|
+
assert.equal(first.ok, true);
|
|
64
|
+
|
|
65
|
+
const second = claimMilestoneLease(w2, "M001");
|
|
66
|
+
assert.equal(second.ok, false);
|
|
67
|
+
if (!second.ok) {
|
|
68
|
+
assert.equal(second.error, "held_by");
|
|
69
|
+
assert.equal(second.byWorker, w1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("releaseMilestoneLease frees the lease for takeover", (t) => {
|
|
74
|
+
const base = makeBase();
|
|
75
|
+
t.after(() => cleanup(base));
|
|
76
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
77
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
78
|
+
|
|
79
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
80
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
81
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
82
|
+
assert.equal(first.ok, true);
|
|
83
|
+
|
|
84
|
+
if (first.ok) {
|
|
85
|
+
const released = releaseMilestoneLease(w1, "M001", first.token);
|
|
86
|
+
assert.equal(released, true);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// After release, w2 may take over with monotonically larger token
|
|
90
|
+
const second = claimMilestoneLease(w2, "M001");
|
|
91
|
+
assert.equal(second.ok, true);
|
|
92
|
+
if (second.ok) {
|
|
93
|
+
assert.equal(second.token, 2, "takeover increments fencing token monotonically");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("expired lease (TTL passed) allows takeover with token+1", (t) => {
|
|
98
|
+
const base = makeBase();
|
|
99
|
+
t.after(() => cleanup(base));
|
|
100
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
101
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
102
|
+
|
|
103
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
104
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
105
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
106
|
+
assert.equal(first.ok, true);
|
|
107
|
+
|
|
108
|
+
// Force expiration by patching the row's expires_at into the past.
|
|
109
|
+
const db = _getAdapter()!;
|
|
110
|
+
db.prepare(
|
|
111
|
+
`UPDATE milestone_leases SET expires_at = '1970-01-01T00:00:00.000Z' WHERE milestone_id = 'M001'`,
|
|
112
|
+
).run();
|
|
113
|
+
|
|
114
|
+
const takeover = claimMilestoneLease(w2, "M001");
|
|
115
|
+
assert.equal(takeover.ok, true);
|
|
116
|
+
if (takeover.ok) {
|
|
117
|
+
assert.equal(takeover.token, 2);
|
|
118
|
+
}
|
|
119
|
+
const row = getMilestoneLease("M001");
|
|
120
|
+
assert.equal(row!.worker_id, w2);
|
|
121
|
+
assert.equal(row!.fencing_token, 2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("refreshMilestoneLease only succeeds with the matching fencing token", (t) => {
|
|
125
|
+
const base = makeBase();
|
|
126
|
+
t.after(() => cleanup(base));
|
|
127
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
128
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
129
|
+
|
|
130
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
131
|
+
const claim = claimMilestoneLease(w1, "M001");
|
|
132
|
+
assert.equal(claim.ok, true);
|
|
133
|
+
if (!claim.ok) return;
|
|
134
|
+
|
|
135
|
+
// Correct token refreshes
|
|
136
|
+
assert.equal(refreshMilestoneLease(w1, "M001", claim.token), true);
|
|
137
|
+
|
|
138
|
+
// Stale token (e.g. claim.token - 1) refuses
|
|
139
|
+
assert.equal(refreshMilestoneLease(w1, "M001", claim.token - 1), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("claimMilestoneLease rethrows foreign-key failures instead of treating them as lease contention", (t) => {
|
|
143
|
+
const base = makeBase();
|
|
144
|
+
t.after(() => cleanup(base));
|
|
145
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
146
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
147
|
+
|
|
148
|
+
assert.throws(
|
|
149
|
+
() => claimMilestoneLease("missing-worker", "M001"),
|
|
150
|
+
/FOREIGN KEY constraint failed/,
|
|
151
|
+
);
|
|
152
|
+
});
|