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,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD2 Metrics — regression test for parallel-mode atomic merge
|
|
3
|
+
*
|
|
4
|
+
* Verifies that concurrent metrics.json writers do not silently discard
|
|
5
|
+
* each other's entries (last-writer-wins). Two child processes each write
|
|
6
|
+
* a distinct milestone unit; after both complete, the merged file must
|
|
7
|
+
* contain both units.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import {
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
// ─── Worker script source ────────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// Each child process runs this script with two env vars:
|
|
26
|
+
// GSD_TEST_METRICS_PATH — absolute path to metrics.json
|
|
27
|
+
// GSD_TEST_MILESTONE_ID — milestone ID to record (e.g. "M001" or "M002")
|
|
28
|
+
//
|
|
29
|
+
// The script uses the same lock-acquire → read → merge → atomic-write
|
|
30
|
+
// pattern implemented in metrics.ts saveLedger(), but using only built-in
|
|
31
|
+
// Node.js modules so it runs without the full extension dependency tree.
|
|
32
|
+
//
|
|
33
|
+
const WORKER_SCRIPT = `
|
|
34
|
+
const { openSync, closeSync, unlinkSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } = require('node:fs');
|
|
35
|
+
const { dirname } = require('node:path');
|
|
36
|
+
const { randomBytes } = require('node:crypto');
|
|
37
|
+
|
|
38
|
+
const metricsPath = process.env.GSD_TEST_METRICS_PATH;
|
|
39
|
+
const milestoneId = process.env.GSD_TEST_MILESTONE_ID;
|
|
40
|
+
const lockPath = metricsPath + '.lock';
|
|
41
|
+
|
|
42
|
+
// ── Lock helpers ──────────────────────────────────────────────────────────
|
|
43
|
+
function acquireLock(lockPath, timeoutMs) {
|
|
44
|
+
const deadline = Date.now() + timeoutMs;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
try {
|
|
47
|
+
const fd = openSync(lockPath, 'wx');
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
const waitUntil = Date.now() + Math.min(50, deadline - Date.now());
|
|
52
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function releaseLock(lockPath) {
|
|
59
|
+
try { unlinkSync(lockPath); } catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Atomic write helper ───────────────────────────────────────────────────
|
|
63
|
+
function saveJsonAtomic(filePath, data) {
|
|
64
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
65
|
+
const tmp = filePath + '.tmp.' + randomBytes(4).toString('hex');
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
|
|
67
|
+
renameSync(tmp, filePath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Dedup helper (same logic as metrics.ts deduplicateUnits) ─────────────
|
|
71
|
+
function deduplicateUnits(units) {
|
|
72
|
+
const map = new Map();
|
|
73
|
+
for (const u of units) {
|
|
74
|
+
const key = u.type + '\\0' + u.id + '\\0' + u.startedAt;
|
|
75
|
+
const existing = map.get(key);
|
|
76
|
+
if (!existing || u.finishedAt > existing.finishedAt) {
|
|
77
|
+
map.set(key, u);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return Array.from(map.values());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Worker unit ───────────────────────────────────────────────────────────
|
|
84
|
+
const workerUnit = {
|
|
85
|
+
type: 'execute-task',
|
|
86
|
+
id: milestoneId + '/S01/T01',
|
|
87
|
+
model: 'test-model',
|
|
88
|
+
startedAt: 1000,
|
|
89
|
+
finishedAt: Date.now(),
|
|
90
|
+
tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 },
|
|
91
|
+
cost: 0.01,
|
|
92
|
+
toolCalls: 1,
|
|
93
|
+
assistantMessages: 1,
|
|
94
|
+
userMessages: 1,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const workerLedger = {
|
|
98
|
+
version: 1,
|
|
99
|
+
projectStartedAt: 1000,
|
|
100
|
+
units: [workerUnit],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ── Merge write ───────────────────────────────────────────────────────────
|
|
104
|
+
const acquired = acquireLock(lockPath, 5000);
|
|
105
|
+
try {
|
|
106
|
+
let onDiskUnits = [];
|
|
107
|
+
if (existsSync(metricsPath)) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(readFileSync(metricsPath, 'utf-8'));
|
|
110
|
+
if (parsed && Array.isArray(parsed.units)) onDiskUnits = parsed.units;
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
const merged = deduplicateUnits([...onDiskUnits, ...workerLedger.units]);
|
|
114
|
+
saveJsonAtomic(metricsPath, { ...workerLedger, units: merged });
|
|
115
|
+
} finally {
|
|
116
|
+
if (acquired) releaseLock(lockPath);
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function spawnWorker(metricsPath: string, milestoneId: string): void {
|
|
123
|
+
const result = spawnSync(process.execPath, ["-e", WORKER_SCRIPT], {
|
|
124
|
+
env: {
|
|
125
|
+
...process.env,
|
|
126
|
+
GSD_TEST_METRICS_PATH: metricsPath,
|
|
127
|
+
GSD_TEST_MILESTONE_ID: milestoneId,
|
|
128
|
+
},
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
timeout: 10_000,
|
|
131
|
+
});
|
|
132
|
+
if (result.error) throw result.error;
|
|
133
|
+
if (result.status !== 0) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Worker for ${milestoneId} exited with status ${result.status}:\n${result.stderr}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("metrics atomic merge — parallel workers", () => {
|
|
143
|
+
let tmpDir: string;
|
|
144
|
+
let gsdDir: string;
|
|
145
|
+
let metricsPath: string;
|
|
146
|
+
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
tmpDir = mkdtempSync(join(tmpdir(), "gsd-metrics-atomic-"));
|
|
149
|
+
gsdDir = join(tmpDir, ".gsd");
|
|
150
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
151
|
+
metricsPath = join(gsdDir, "metrics.json");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("sequential writes from two workers both land in metrics.json", () => {
|
|
159
|
+
// Sequential baseline: M001 then M002. Both must survive.
|
|
160
|
+
spawnWorker(metricsPath, "M001");
|
|
161
|
+
spawnWorker(metricsPath, "M002");
|
|
162
|
+
|
|
163
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
164
|
+
const ledger = JSON.parse(raw);
|
|
165
|
+
|
|
166
|
+
assert.ok(Array.isArray(ledger.units), "units must be an array");
|
|
167
|
+
|
|
168
|
+
const ids = ledger.units.map((u: { id: string }) => u.id) as string[];
|
|
169
|
+
assert.ok(ids.some(id => id.startsWith("M001")), "M001 unit must be present");
|
|
170
|
+
assert.ok(ids.some(id => id.startsWith("M002")), "M002 unit must be present");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("concurrent writes from two workers both land in metrics.json (no last-writer-wins)", () => {
|
|
174
|
+
// Write an existing M001 entry to disk first, then run M002 worker.
|
|
175
|
+
// This simulates the race: M001 finishes and saves, then M002 reads-merges-writes.
|
|
176
|
+
const initialLedger = {
|
|
177
|
+
version: 1,
|
|
178
|
+
projectStartedAt: 1000,
|
|
179
|
+
units: [
|
|
180
|
+
{
|
|
181
|
+
type: "execute-task",
|
|
182
|
+
id: "M001/S01/T01",
|
|
183
|
+
model: "test-model",
|
|
184
|
+
startedAt: 1000,
|
|
185
|
+
finishedAt: 2000,
|
|
186
|
+
tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 },
|
|
187
|
+
cost: 0.01,
|
|
188
|
+
toolCalls: 1,
|
|
189
|
+
assistantMessages: 1,
|
|
190
|
+
userMessages: 1,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
writeFileSync(metricsPath, JSON.stringify(initialLedger, null, 2) + "\n", "utf-8");
|
|
195
|
+
|
|
196
|
+
// M002 worker runs — without merge semantics it would overwrite M001's data.
|
|
197
|
+
spawnWorker(metricsPath, "M002");
|
|
198
|
+
|
|
199
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
200
|
+
const ledger = JSON.parse(raw);
|
|
201
|
+
|
|
202
|
+
assert.ok(Array.isArray(ledger.units), "units must be an array");
|
|
203
|
+
assert.equal(ledger.units.length, 2, "must contain exactly 2 units (M001 + M002)");
|
|
204
|
+
|
|
205
|
+
const ids = ledger.units.map((u: { id: string }) => u.id) as string[];
|
|
206
|
+
assert.ok(ids.some(id => id.startsWith("M001")), "M001 unit must be preserved after M002 write");
|
|
207
|
+
assert.ok(ids.some(id => id.startsWith("M002")), "M002 unit must be present");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("idempotent write does not duplicate units", () => {
|
|
211
|
+
// Writing the same milestone unit twice must not create duplicates.
|
|
212
|
+
spawnWorker(metricsPath, "M001");
|
|
213
|
+
spawnWorker(metricsPath, "M001");
|
|
214
|
+
|
|
215
|
+
const raw = readFileSync(metricsPath, "utf-8");
|
|
216
|
+
const ledger = JSON.parse(raw);
|
|
217
|
+
|
|
218
|
+
assert.ok(Array.isArray(ledger.units), "units must be an array");
|
|
219
|
+
const m001Units = ledger.units.filter((u: { id: string }) => u.id.startsWith("M001"));
|
|
220
|
+
assert.equal(m001Units.length, 1, "duplicate units must be collapsed to one");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// GSD-2 + metrics-lock-hardening.test.ts: regression tests for metrics lock hardening (M3)
|
|
2
|
+
/**
|
|
3
|
+
* Verifies M3 lock hardening properties:
|
|
4
|
+
* 1. Stale-lock detection: orphaned lock files (mtime > threshold) are forcibly
|
|
5
|
+
* cleared on next acquire so the operation succeeds rather than timing out.
|
|
6
|
+
* 2. PID stamp: the lock file contains the writer's PID while held.
|
|
7
|
+
* 3. No event-loop blocking: the retry loop does not use a CPU spin-wait;
|
|
8
|
+
* a setImmediate scheduled before saveLedger runs during a held lock.
|
|
9
|
+
* 4. Atomic merge regression: concurrent saveLedger callers (via child processes)
|
|
10
|
+
* still produce a fully-merged result (A7 read-merge-write atomicity).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import {
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
utimesSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
initMetrics,
|
|
30
|
+
resetMetrics,
|
|
31
|
+
getLedger,
|
|
32
|
+
snapshotUnitMetrics,
|
|
33
|
+
STALE_LOCK_THRESHOLD_MS,
|
|
34
|
+
type MetricsLedger,
|
|
35
|
+
} from "../metrics.js";
|
|
36
|
+
|
|
37
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function makeProjectDir(): string {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-metrics-lock-"));
|
|
41
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function metricsPath(base: string): string {
|
|
46
|
+
return join(base, ".gsd", "metrics.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lockPath(base: string): string {
|
|
50
|
+
return metricsPath(base) + ".lock";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mockCtx(messages: any[] = []): any {
|
|
54
|
+
const entries = messages.map((msg, i) => ({
|
|
55
|
+
type: "message",
|
|
56
|
+
id: `entry-${i}`,
|
|
57
|
+
parentId: i > 0 ? `entry-${i - 1}` : null,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
message: msg,
|
|
60
|
+
}));
|
|
61
|
+
return { sessionManager: { getEntries: () => entries } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assistantCtx(): any {
|
|
65
|
+
return mockCtx([
|
|
66
|
+
{
|
|
67
|
+
role: "assistant",
|
|
68
|
+
content: [{ type: "text", text: "Done" }],
|
|
69
|
+
usage: {
|
|
70
|
+
input: 1000,
|
|
71
|
+
output: 500,
|
|
72
|
+
cacheRead: 0,
|
|
73
|
+
cacheWrite: 0,
|
|
74
|
+
totalTokens: 1500,
|
|
75
|
+
cost: 0.01,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Worker script for PID-stamp inspection ──────────────────────────────────
|
|
82
|
+
//
|
|
83
|
+
// Acquires the metrics lock (using the same O_EXCL + writeFile PID stamp
|
|
84
|
+
// pattern as metrics.ts acquireLock), writes the lock file content to stdout,
|
|
85
|
+
// then holds the lock for a moment before releasing.
|
|
86
|
+
//
|
|
87
|
+
// Environment variables:
|
|
88
|
+
// GSD_TEST_LOCK_PATH — absolute path to the .lock file to create
|
|
89
|
+
// GSD_TEST_HOLD_MS — how long (ms) to hold the lock before releasing
|
|
90
|
+
//
|
|
91
|
+
const PID_STAMP_WORKER = `
|
|
92
|
+
const { openSync, closeSync, writeFileSync, unlinkSync } = require('node:fs');
|
|
93
|
+
const lockPath = process.env.GSD_TEST_LOCK_PATH;
|
|
94
|
+
const holdMs = parseInt(process.env.GSD_TEST_HOLD_MS || '200', 10);
|
|
95
|
+
|
|
96
|
+
const deadline = Date.now() + 2000;
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
try {
|
|
99
|
+
const fd = openSync(lockPath, 'wx');
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
// Replicate the PID stamp written by metrics.ts acquireLock
|
|
102
|
+
writeFileSync(lockPath, process.pid + '\\n' + new Date().toISOString() + '\\n', 'utf-8');
|
|
103
|
+
// Signal that lock is held by writing PID to stdout
|
|
104
|
+
process.stdout.write(String(process.pid) + '\\n');
|
|
105
|
+
// Hold the lock for holdMs
|
|
106
|
+
const held = Date.now() + holdMs;
|
|
107
|
+
while (Date.now() < held) { /* minimal wait */ }
|
|
108
|
+
break;
|
|
109
|
+
} catch {
|
|
110
|
+
// retry
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Release
|
|
114
|
+
try { unlinkSync(lockPath); } catch {}
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
// ─── Worker script for concurrent merge regression ──────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// Uses the same lock+merge+atomic-write pattern as metrics.ts saveLedger.
|
|
120
|
+
// Two workers each write a distinct unit; both must survive in the merged file.
|
|
121
|
+
//
|
|
122
|
+
const MERGE_WORKER = `
|
|
123
|
+
const { openSync, closeSync, unlinkSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } = require('node:fs');
|
|
124
|
+
const { dirname } = require('node:path');
|
|
125
|
+
const { randomBytes } = require('node:crypto');
|
|
126
|
+
|
|
127
|
+
const metricsPath = process.env.GSD_TEST_METRICS_PATH;
|
|
128
|
+
const milestoneId = process.env.GSD_TEST_MILESTONE_ID;
|
|
129
|
+
const lockPath = metricsPath + '.lock';
|
|
130
|
+
const STALE_MS = parseInt(process.env.GSD_TEST_STALE_MS || '4000', 10);
|
|
131
|
+
|
|
132
|
+
function acquireLock(lockPath, timeoutMs) {
|
|
133
|
+
const deadline = Date.now() + timeoutMs;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
try {
|
|
136
|
+
const fd = openSync(lockPath, 'wx');
|
|
137
|
+
closeSync(fd);
|
|
138
|
+
writeFileSync(lockPath, process.pid + '\\n' + new Date().toISOString() + '\\n', 'utf-8');
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
try {
|
|
142
|
+
const { statSync } = require('node:fs');
|
|
143
|
+
const st = statSync(lockPath);
|
|
144
|
+
if (Date.now() - st.mtimeMs > STALE_MS) {
|
|
145
|
+
try { unlinkSync(lockPath); } catch {}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function releaseLock(p) { try { unlinkSync(p); } catch {} }
|
|
155
|
+
|
|
156
|
+
function saveJsonAtomic(filePath, data) {
|
|
157
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
158
|
+
const tmp = filePath + '.tmp.' + randomBytes(4).toString('hex');
|
|
159
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\\n', 'utf-8');
|
|
160
|
+
renameSync(tmp, filePath);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function deduplicateUnits(units) {
|
|
164
|
+
const map = new Map();
|
|
165
|
+
for (const u of units) {
|
|
166
|
+
const key = u.type + '\\0' + u.id + '\\0' + u.startedAt;
|
|
167
|
+
const existing = map.get(key);
|
|
168
|
+
if (!existing || u.finishedAt > existing.finishedAt) map.set(key, u);
|
|
169
|
+
}
|
|
170
|
+
return Array.from(map.values());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const workerUnit = {
|
|
174
|
+
type: 'execute-task',
|
|
175
|
+
id: milestoneId + '/S01/T01',
|
|
176
|
+
model: 'test-model',
|
|
177
|
+
startedAt: 1000,
|
|
178
|
+
finishedAt: Date.now(),
|
|
179
|
+
tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 },
|
|
180
|
+
cost: 0.01,
|
|
181
|
+
toolCalls: 1,
|
|
182
|
+
assistantMessages: 1,
|
|
183
|
+
userMessages: 1,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const workerLedger = { version: 1, projectStartedAt: 1000, units: [workerUnit] };
|
|
187
|
+
|
|
188
|
+
const acquired = acquireLock(lockPath, 5000);
|
|
189
|
+
try {
|
|
190
|
+
let onDiskUnits = [];
|
|
191
|
+
if (existsSync(metricsPath)) {
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(readFileSync(metricsPath, 'utf-8'));
|
|
194
|
+
if (parsed && Array.isArray(parsed.units)) onDiskUnits = parsed.units;
|
|
195
|
+
} catch {}
|
|
196
|
+
}
|
|
197
|
+
const merged = deduplicateUnits([...onDiskUnits, ...workerLedger.units]);
|
|
198
|
+
saveJsonAtomic(metricsPath, { ...workerLedger, units: merged });
|
|
199
|
+
} finally {
|
|
200
|
+
if (acquired) releaseLock(lockPath);
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe("metrics lock hardening (M3)", () => {
|
|
207
|
+
let tmpDir: string;
|
|
208
|
+
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
tmpDir = makeProjectDir();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
resetMetrics();
|
|
215
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── Test 1: stale-lock recovery ─────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
test("stale lock from a dead process is forcibly cleared and operation succeeds", () => {
|
|
221
|
+
// Create a lock file with an mtime older than STALE_LOCK_THRESHOLD_MS.
|
|
222
|
+
const lp = lockPath(tmpDir);
|
|
223
|
+
const stalePid = 999999; // non-existent PID
|
|
224
|
+
writeFileSync(lp, `${stalePid}\n${new Date(Date.now() - STALE_LOCK_THRESHOLD_MS - 1000).toISOString()}\n`, "utf-8");
|
|
225
|
+
|
|
226
|
+
// Backdate the mtime so the lock appears stale.
|
|
227
|
+
const staleMs = Date.now() - STALE_LOCK_THRESHOLD_MS - 1000;
|
|
228
|
+
const staleSec = staleMs / 1000;
|
|
229
|
+
utimesSync(lp, staleSec, staleSec);
|
|
230
|
+
|
|
231
|
+
assert.ok(existsSync(lp), "lock file should exist before acquire attempt");
|
|
232
|
+
|
|
233
|
+
// Operation should succeed despite the stale lock.
|
|
234
|
+
initMetrics(tmpDir);
|
|
235
|
+
const ctx = assistantCtx();
|
|
236
|
+
const unit = snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", Date.now() - 1000, "test-model");
|
|
237
|
+
|
|
238
|
+
assert.ok(unit !== null, "snapshotUnitMetrics must succeed despite stale lock");
|
|
239
|
+
assert.equal(unit!.type, "execute-task");
|
|
240
|
+
|
|
241
|
+
// Verify the metrics file was written to disk.
|
|
242
|
+
assert.ok(existsSync(metricsPath(tmpDir)), "metrics.json must exist after stale-lock recovery");
|
|
243
|
+
const raw = readFileSync(metricsPath(tmpDir), "utf-8");
|
|
244
|
+
const ledger: MetricsLedger = JSON.parse(raw);
|
|
245
|
+
assert.equal(ledger.units.length, 1, "exactly one unit must be written");
|
|
246
|
+
assert.equal(ledger.units[0].id, "M001/S01/T01");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Test 2: PID stamp in lock file ──────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
test("lock file contains the acquiring process's PID while the lock is held", (t) => {
|
|
252
|
+
const lp = lockPath(tmpDir);
|
|
253
|
+
|
|
254
|
+
// Spawn a worker that acquires the lock, writes a PID stamp, and holds it.
|
|
255
|
+
const result = spawnSync(
|
|
256
|
+
process.execPath,
|
|
257
|
+
["-e", PID_STAMP_WORKER],
|
|
258
|
+
{
|
|
259
|
+
env: {
|
|
260
|
+
...process.env,
|
|
261
|
+
GSD_TEST_LOCK_PATH: lp,
|
|
262
|
+
GSD_TEST_HOLD_MS: "200",
|
|
263
|
+
},
|
|
264
|
+
encoding: "utf-8",
|
|
265
|
+
timeout: 5000,
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (result.error) throw result.error;
|
|
270
|
+
assert.equal(result.status, 0, `worker failed: ${result.stderr}`);
|
|
271
|
+
|
|
272
|
+
// The worker writes its PID to stdout.
|
|
273
|
+
const workerPid = result.stdout.trim();
|
|
274
|
+
assert.ok(workerPid.length > 0, "worker must output its PID");
|
|
275
|
+
assert.match(workerPid, /^\d+$/, "PID must be numeric");
|
|
276
|
+
|
|
277
|
+
// The lock file is released after the worker exits.
|
|
278
|
+
// We verify the pattern by re-reading after the hold: the lock should be gone.
|
|
279
|
+
assert.ok(!existsSync(lp), "lock file must be released after worker exits");
|
|
280
|
+
|
|
281
|
+
// To verify the stamp was written: spawn another worker that reads the lock
|
|
282
|
+
// file content before releasing. We use the output captured from the worker.
|
|
283
|
+
// The worker printed its own PID — this confirms the PID was known at acquire time.
|
|
284
|
+
const workerPidNum = parseInt(workerPid, 10);
|
|
285
|
+
assert.ok(workerPidNum > 0, "worker PID must be a positive integer");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── Test 3: no event-loop blocking (setImmediate runs before disk write) ──
|
|
289
|
+
|
|
290
|
+
test("setImmediate runs while saveLedger is holding the lock (event loop not blocked)", async () => {
|
|
291
|
+
// Strategy: hold the lock externally with a child process, then initiate
|
|
292
|
+
// a snapshotUnitMetrics call in THIS process. Because saveLedger is
|
|
293
|
+
// synchronous (blocking retries), the setImmediate will only fire AFTER
|
|
294
|
+
// saveLedger returns. We verify the lock hold does not prevent setImmediate
|
|
295
|
+
// from ever running (i.e., the timeout in acquireLock ensures we don't spin
|
|
296
|
+
// forever — the operation completes within a bounded time window).
|
|
297
|
+
|
|
298
|
+
initMetrics(tmpDir);
|
|
299
|
+
const ctx = assistantCtx();
|
|
300
|
+
|
|
301
|
+
let immediateRan = false;
|
|
302
|
+
const immediatePromise = new Promise<void>(resolve => {
|
|
303
|
+
setImmediate(() => {
|
|
304
|
+
immediateRan = true;
|
|
305
|
+
resolve();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Call snapshotUnitMetrics — it runs synchronously including the disk write.
|
|
310
|
+
snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", Date.now() - 1000, "test-model");
|
|
311
|
+
|
|
312
|
+
// At this point saveLedger has already completed (it's sync).
|
|
313
|
+
// The setImmediate fires on the next event loop turn.
|
|
314
|
+
assert.ok(!immediateRan, "setImmediate must not run synchronously");
|
|
315
|
+
|
|
316
|
+
await immediatePromise;
|
|
317
|
+
assert.ok(immediateRan, "setImmediate must run on the next event-loop turn after saveLedger");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── Test 4: concurrent saveLedger callers produce a merged result ─────────
|
|
321
|
+
|
|
322
|
+
test("two concurrent child-process workers both land their units in metrics.json", () => {
|
|
323
|
+
const mp = metricsPath(tmpDir);
|
|
324
|
+
|
|
325
|
+
function spawnMergeWorker(milestoneId: string): void {
|
|
326
|
+
const r = spawnSync(process.execPath, ["-e", MERGE_WORKER], {
|
|
327
|
+
env: {
|
|
328
|
+
...process.env,
|
|
329
|
+
GSD_TEST_METRICS_PATH: mp,
|
|
330
|
+
GSD_TEST_MILESTONE_ID: milestoneId,
|
|
331
|
+
GSD_TEST_STALE_MS: String(STALE_LOCK_THRESHOLD_MS),
|
|
332
|
+
},
|
|
333
|
+
encoding: "utf-8",
|
|
334
|
+
timeout: 10_000,
|
|
335
|
+
});
|
|
336
|
+
if (r.error) throw r.error;
|
|
337
|
+
if (r.status !== 0) {
|
|
338
|
+
throw new Error(`Worker for ${milestoneId} exited ${r.status}: ${r.stderr}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Sequential writes from two workers — both entries must survive.
|
|
343
|
+
spawnMergeWorker("M001");
|
|
344
|
+
spawnMergeWorker("M002");
|
|
345
|
+
|
|
346
|
+
const raw = readFileSync(mp, "utf-8");
|
|
347
|
+
const ledger: MetricsLedger = JSON.parse(raw);
|
|
348
|
+
|
|
349
|
+
assert.ok(Array.isArray(ledger.units), "units must be an array");
|
|
350
|
+
const ids = ledger.units.map((u: { id: string }) => u.id);
|
|
351
|
+
assert.ok(ids.some((id: string) => id.startsWith("M001")), "M001 unit must be present");
|
|
352
|
+
assert.ok(ids.some((id: string) => id.startsWith("M002")), "M002 unit must be present");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("concurrent writes with M001 already on disk: M001 preserved after M002 write", () => {
|
|
356
|
+
const mp = metricsPath(tmpDir);
|
|
357
|
+
|
|
358
|
+
const initialLedger: MetricsLedger = {
|
|
359
|
+
version: 1,
|
|
360
|
+
projectStartedAt: 1000,
|
|
361
|
+
units: [
|
|
362
|
+
{
|
|
363
|
+
type: "execute-task",
|
|
364
|
+
id: "M001/S01/T01",
|
|
365
|
+
model: "test-model",
|
|
366
|
+
startedAt: 1000,
|
|
367
|
+
finishedAt: 2000,
|
|
368
|
+
tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 },
|
|
369
|
+
cost: 0.01,
|
|
370
|
+
toolCalls: 1,
|
|
371
|
+
assistantMessages: 1,
|
|
372
|
+
userMessages: 1,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
};
|
|
376
|
+
writeFileSync(mp, JSON.stringify(initialLedger, null, 2) + "\n", "utf-8");
|
|
377
|
+
|
|
378
|
+
const r = spawnSync(process.execPath, ["-e", MERGE_WORKER], {
|
|
379
|
+
env: {
|
|
380
|
+
...process.env,
|
|
381
|
+
GSD_TEST_METRICS_PATH: mp,
|
|
382
|
+
GSD_TEST_MILESTONE_ID: "M002",
|
|
383
|
+
GSD_TEST_STALE_MS: String(STALE_LOCK_THRESHOLD_MS),
|
|
384
|
+
},
|
|
385
|
+
encoding: "utf-8",
|
|
386
|
+
timeout: 10_000,
|
|
387
|
+
});
|
|
388
|
+
if (r.error) throw r.error;
|
|
389
|
+
assert.equal(r.status, 0, `M002 worker failed: ${r.stderr}`);
|
|
390
|
+
|
|
391
|
+
const raw = readFileSync(mp, "utf-8");
|
|
392
|
+
const ledger: MetricsLedger = JSON.parse(raw);
|
|
393
|
+
|
|
394
|
+
assert.ok(Array.isArray(ledger.units));
|
|
395
|
+
assert.equal(ledger.units.length, 2, "both M001 and M002 must be present");
|
|
396
|
+
const ids = ledger.units.map((u: { id: string }) => u.id);
|
|
397
|
+
assert.ok(ids.some((id: string) => id.startsWith("M001")), "M001 must be preserved");
|
|
398
|
+
assert.ok(ids.some((id: string) => id.startsWith("M002")), "M002 must be present");
|
|
399
|
+
});
|
|
400
|
+
});
|