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.
Files changed (212) hide show
  1. package/README.md +5 -7
  2. package/dist/help-text.js +1 -1
  3. package/dist/resource-loader.js +6 -1
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
  6. package/dist/resources/extensions/gsd/auto/loop.js +235 -36
  7. package/dist/resources/extensions/gsd/auto/phases.js +14 -7
  8. package/dist/resources/extensions/gsd/auto/session.js +36 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +49 -4
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +26 -12
  11. package/dist/resources/extensions/gsd/auto-worktree.js +185 -201
  12. package/dist/resources/extensions/gsd/auto.js +139 -49
  13. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +26 -20
  15. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
  16. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  17. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  18. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  19. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  20. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  21. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  22. package/dist/resources/extensions/gsd/db-writer.js +96 -16
  23. package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
  24. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  25. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  26. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  27. package/dist/resources/extensions/gsd/doctor.js +12 -2
  28. package/dist/resources/extensions/gsd/gsd-db.js +355 -3
  29. package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
  30. package/dist/resources/extensions/gsd/guided-flow.js +116 -26
  31. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  32. package/dist/resources/extensions/gsd/metrics.js +287 -1
  33. package/dist/resources/extensions/gsd/paths.js +79 -8
  34. package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  35. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
  36. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  38. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  40. package/dist/resources/extensions/gsd/state.js +21 -6
  41. package/dist/resources/extensions/gsd/templates/project.md +10 -0
  42. package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
  43. package/dist/resources/extensions/gsd/workspace.js +59 -0
  44. package/dist/resources/extensions/gsd/worktree-resolver.js +79 -2
  45. package/dist/resources/extensions/gsd/write-intercept.js +3 -3
  46. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  47. package/dist/web/standalone/.next/BUILD_ID +1 -1
  48. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  49. package/dist/web/standalone/.next/build-manifest.json +2 -2
  50. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  51. package/dist/web/standalone/.next/required-server-files.json +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.html +1 -1
  69. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  76. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  78. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  79. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  80. package/dist/web/standalone/server.js +1 -1
  81. package/package.json +1 -1
  82. package/packages/mcp-server/README.md +2 -11
  83. package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
  84. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  85. package/packages/mcp-server/dist/remote-questions.js +28 -0
  86. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  87. package/packages/mcp-server/dist/server.d.ts +28 -0
  88. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  89. package/packages/mcp-server/dist/server.js +94 -4
  90. package/packages/mcp-server/dist/server.js.map +1 -1
  91. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  92. package/packages/mcp-server/src/mcp-server.test.ts +226 -0
  93. package/packages/mcp-server/src/remote-questions.test.ts +103 -0
  94. package/packages/mcp-server/src/remote-questions.ts +35 -0
  95. package/packages/mcp-server/src/server.ts +129 -6
  96. package/packages/mcp-server/src/workflow-tools.ts +1 -1
  97. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  98. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  99. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  100. package/src/resources/extensions/gsd/auto/phases.ts +15 -7
  101. package/src/resources/extensions/gsd/auto/session.ts +40 -0
  102. package/src/resources/extensions/gsd/auto-dispatch.ts +63 -4
  103. package/src/resources/extensions/gsd/auto-post-unit.ts +27 -12
  104. package/src/resources/extensions/gsd/auto-worktree.ts +218 -225
  105. package/src/resources/extensions/gsd/auto.ts +166 -43
  106. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
  107. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +26 -21
  108. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
  109. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
  110. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  111. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  112. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  113. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  114. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  115. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  116. package/src/resources/extensions/gsd/db-writer.ts +113 -17
  117. package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
  118. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  119. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  120. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  121. package/src/resources/extensions/gsd/doctor.ts +10 -2
  122. package/src/resources/extensions/gsd/gsd-db.ts +354 -3
  123. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
  124. package/src/resources/extensions/gsd/guided-flow.ts +152 -26
  125. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  126. package/src/resources/extensions/gsd/metrics.ts +321 -1
  127. package/src/resources/extensions/gsd/paths.ts +67 -8
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  129. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
  130. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  131. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  134. package/src/resources/extensions/gsd/state.ts +44 -6
  135. package/src/resources/extensions/gsd/templates/project.md +10 -0
  136. package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
  137. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  138. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  139. package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
  140. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  141. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
  142. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  143. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  144. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  145. package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
  146. package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
  147. package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
  148. package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
  149. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  150. package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
  151. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
  152. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
  153. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
  154. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
  155. package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
  156. package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
  157. package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
  158. package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
  159. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
  160. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  161. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  162. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  163. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +369 -0
  164. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  165. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  166. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  167. package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
  168. package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
  169. package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
  170. package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
  171. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
  172. package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
  173. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  174. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
  175. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  176. package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
  177. package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
  178. package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
  179. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  180. package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
  181. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  182. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  183. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
  184. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +138 -16
  185. package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
  186. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  187. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  188. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  189. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  190. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
  192. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +98 -0
  193. package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
  194. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
  195. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  196. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  197. package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
  198. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
  199. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
  200. package/src/resources/extensions/gsd/tests/workspace.test.ts +196 -0
  201. package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
  202. package/src/resources/extensions/gsd/tests/write-gate.test.ts +94 -71
  203. package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
  204. package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
  205. package/src/resources/extensions/gsd/workspace.ts +95 -0
  206. package/src/resources/extensions/gsd/worktree-resolver.ts +78 -2
  207. package/src/resources/extensions/gsd/write-intercept.ts +3 -3
  208. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  209. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  210. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  211. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  212. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -0,0 +1,141 @@
1
+ // GSD-2 + metrics saveLedger fallback: when lock is not acquired, falls back to direct write (safe, no torn write)
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
+ writeFileSync,
11
+ existsSync,
12
+ openSync,
13
+ closeSync,
14
+ } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import {
19
+ initMetrics,
20
+ resetMetrics,
21
+ snapshotUnitMetrics,
22
+ type MetricsLedger,
23
+ } from "../metrics.js";
24
+
25
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
26
+
27
+ function makeProjectDir(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "gsd-metrics-lock-na-"));
29
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
30
+ return dir;
31
+ }
32
+
33
+ function metricsPath(base: string): string {
34
+ return join(base, ".gsd", "metrics.json");
35
+ }
36
+
37
+ function lockPath(base: string): string {
38
+ return metricsPath(base) + ".lock";
39
+ }
40
+
41
+ function assistantCtx(): any {
42
+ const entries = [
43
+ {
44
+ type: "message",
45
+ id: "entry-0",
46
+ parentId: null,
47
+ timestamp: new Date().toISOString(),
48
+ message: {
49
+ role: "assistant",
50
+ content: [{ type: "text", text: "Done" }],
51
+ usage: {
52
+ input: 1000,
53
+ output: 500,
54
+ cacheRead: 0,
55
+ cacheWrite: 0,
56
+ totalTokens: 1500,
57
+ cost: 0.01,
58
+ },
59
+ },
60
+ },
61
+ ];
62
+ return { sessionManager: { getEntries: () => entries } };
63
+ }
64
+
65
+ // ─── Tests ───────────────────────────────────────────────────────────────────
66
+
67
+ describe("saveLedger: fallback behavior when lock is not acquired", () => {
68
+ let tmpDir: string;
69
+
70
+ beforeEach(() => {
71
+ tmpDir = makeProjectDir();
72
+ });
73
+
74
+ afterEach(() => {
75
+ resetMetrics();
76
+ // Clean up lock file if test left it
77
+ try { rmSync(lockPath(tmpDir), { force: true }); } catch {}
78
+ rmSync(tmpDir, { recursive: true, force: true });
79
+ });
80
+
81
+ test(
82
+ "saveLedger falls back to direct write and produces a valid metrics file when lock times out",
83
+ { timeout: 10000 }, // 10s to accommodate the 2s lock acquire timeout
84
+ () => {
85
+ const lp = lockPath(tmpDir);
86
+
87
+ // Simulate another process holding the lock: create the lock file with a
88
+ // fresh mtime so acquireLock cannot evict it as stale. acquireLock will
89
+ // retry for its full 2s timeout then return false, triggering the fallback.
90
+ const fd = openSync(lp, "w");
91
+ closeSync(fd);
92
+ writeFileSync(lp, `99999\n${new Date().toISOString()}\n`, "utf-8");
93
+
94
+ // Initialize metrics and snapshot — snapshotUnitMetrics calls saveLedger
95
+ // internally, which will timeout on the held lock and fall back to a direct
96
+ // write instead of proceeding unprotected through the read-merge-write path.
97
+ initMetrics(tmpDir);
98
+
99
+ const ctx = assistantCtx();
100
+ const unit = snapshotUnitMetrics(
101
+ ctx,
102
+ "execute-task",
103
+ "M001/S01/T01",
104
+ Date.now() - 1000,
105
+ "test-model",
106
+ );
107
+ assert.ok(
108
+ unit !== null,
109
+ "snapshotUnitMetrics must return a unit even when lock is held",
110
+ );
111
+
112
+ // The metrics file must exist — fallback direct write succeeded.
113
+ assert.ok(
114
+ existsSync(metricsPath(tmpDir)),
115
+ "metrics.json must exist after saveLedger fallback write",
116
+ );
117
+
118
+ // The metrics file must be valid JSON containing the snapshotted unit.
119
+ const raw = readFileSync(metricsPath(tmpDir), "utf-8");
120
+ let ledger: MetricsLedger;
121
+ assert.doesNotThrow(() => {
122
+ ledger = JSON.parse(raw) as MetricsLedger;
123
+ }, "metrics.json must be valid JSON after fallback write");
124
+ assert.ok(Array.isArray(ledger!.units), "metrics.json must have a units array");
125
+ assert.ok(
126
+ ledger!.units.length > 0,
127
+ "metrics.json must contain the snapshotted unit",
128
+ );
129
+
130
+ // The lock file must still exist — saveLedger must not release a lock
131
+ // that it did not acquire (no double-free / unlink of another process's lock).
132
+ assert.ok(
133
+ existsSync(lp),
134
+ "lock file must remain untouched (saveLedger must not release a lock it did not acquire)",
135
+ );
136
+
137
+ // Release our manually-held lock so afterEach cleanup works cleanly.
138
+ rmSync(lp, { force: true });
139
+ },
140
+ );
141
+ });
@@ -0,0 +1,287 @@
1
+ // GSD-2 + metrics-lock-retry-sleep.test.ts: verify sleep between lock acquire retries (M3 follow-up)
2
+ /**
3
+ * Verifies that acquireLock sleeps between non-stale-evicting retries:
4
+ *
5
+ * 1. Under contention: a child process holds the lock for 100ms; the main
6
+ * process acquireLock attempt should make at most ~30 sleepy retries
7
+ * (100ms contention / 5ms sleep) — not thousands as would occur without
8
+ * the sleep.
9
+ *
10
+ * 2. Stale-lock eviction path: when a stale lock is detected and forcibly
11
+ * removed, the subsequent acquire attempt does NOT sleep — the sleepy
12
+ * retry counter stays at zero and the acquire succeeds immediately.
13
+ *
14
+ * 3. Regression: M3 lock-hardening tests still pass (invoked separately
15
+ * as part of the test suite — tested here by exercising the same
16
+ * stale-lock + PID-stamp code paths through snapshotUnitMetrics).
17
+ */
18
+
19
+ import { describe, test, beforeEach, afterEach } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import {
22
+ mkdtempSync,
23
+ mkdirSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ existsSync,
27
+ utimesSync,
28
+ } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { tmpdir } from "node:os";
31
+ import { spawnSync, spawn } from "node:child_process";
32
+
33
+ import {
34
+ initMetrics,
35
+ resetMetrics,
36
+ snapshotUnitMetrics,
37
+ STALE_LOCK_THRESHOLD_MS,
38
+ LOCK_RETRY_INTERVAL_MS,
39
+ getLockSleepyRetries,
40
+ resetLockSleepyRetries,
41
+ } from "../metrics.js";
42
+
43
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
44
+
45
+ function makeProjectDir(): string {
46
+ const dir = mkdtempSync(join(tmpdir(), "gsd-metrics-sleep-"));
47
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
48
+ return dir;
49
+ }
50
+
51
+ function metricsPath(base: string): string {
52
+ return join(base, ".gsd", "metrics.json");
53
+ }
54
+
55
+ function lockPath(base: string): string {
56
+ return metricsPath(base) + ".lock";
57
+ }
58
+
59
+ function assistantCtx(): any {
60
+ return {
61
+ sessionManager: {
62
+ getEntries: () => [
63
+ {
64
+ type: "message",
65
+ id: "entry-0",
66
+ parentId: null,
67
+ timestamp: new Date().toISOString(),
68
+ message: {
69
+ role: "assistant",
70
+ content: [{ type: "text", text: "Done" }],
71
+ usage: {
72
+ input: 100,
73
+ output: 50,
74
+ cacheRead: 0,
75
+ cacheWrite: 0,
76
+ totalTokens: 150,
77
+ cost: 0.001,
78
+ },
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ };
84
+ }
85
+
86
+ // Worker that acquires the lock using O_EXCL and holds it for holdMs, then releases.
87
+ // Writes its PID to stdout once the lock is acquired so the caller can synchronize.
88
+ const LOCK_HOLDER_WORKER = `
89
+ const { openSync, closeSync, writeFileSync, unlinkSync } = require('node:fs');
90
+ const lockPath = process.env.GSD_TEST_LOCK_PATH;
91
+ const holdMs = parseInt(process.env.GSD_TEST_HOLD_MS || '100', 10);
92
+
93
+ const deadline = Date.now() + 3000;
94
+ let acquired = false;
95
+ while (Date.now() < deadline) {
96
+ try {
97
+ const fd = openSync(lockPath, 'wx');
98
+ closeSync(fd);
99
+ writeFileSync(lockPath, process.pid + '\\n' + new Date().toISOString() + '\\n', 'utf-8');
100
+ acquired = true;
101
+ break;
102
+ } catch { /* retry */ }
103
+ }
104
+
105
+ if (!acquired) {
106
+ process.stderr.write('Worker failed to acquire lock\\n');
107
+ process.exit(1);
108
+ }
109
+
110
+ // Signal that the lock is held.
111
+ process.stdout.write(String(process.pid) + '\\n');
112
+
113
+ // Hold the lock.
114
+ const releaseAt = Date.now() + holdMs;
115
+ while (Date.now() < releaseAt) { /* spin for short hold */ }
116
+
117
+ try { unlinkSync(lockPath); } catch {}
118
+ `;
119
+
120
+ // ─── Tests ────────────────────────────────────────────────────────────────────
121
+
122
+ describe("metrics lock retry sleep (M3 follow-up)", () => {
123
+ let tmpDir: string;
124
+
125
+ beforeEach(() => {
126
+ tmpDir = makeProjectDir();
127
+ resetLockSleepyRetries();
128
+ });
129
+
130
+ afterEach(() => {
131
+ resetMetrics();
132
+ rmSync(tmpDir, { recursive: true, force: true });
133
+ });
134
+
135
+ // ── Test 1: Under contention, sleep caps sleepy retries ───────────────────
136
+
137
+ test("sleepy retry count is bounded under lock contention (5ms sleep, 500ms timeout)", () => {
138
+ const lp = lockPath(tmpDir);
139
+
140
+ // Spawn a child that holds the lock for 100ms.
141
+ // We use spawnSync with a timeout > holdMs so it completes before our check.
142
+ // But we need the child to hold first THEN we attempt. Since spawnSync blocks,
143
+ // we pre-create the lock file instead to simulate contention for 100ms.
144
+
145
+ // Simulate: lock is held for 100ms from now, then released.
146
+ // We create the lock file manually (as if another process holds it) and
147
+ // schedule its removal after 100ms using a child process that holds then deletes.
148
+ //
149
+ // Strategy: use a background worker via spawnSync with hold=100ms,
150
+ // but we can't overlap with spawnSync. Instead, we directly test with
151
+ // snapshotUnitMetrics + a pre-placed lock + a child that will remove it.
152
+ //
153
+ // Simplest approach: place the lock file, run snapshotUnitMetrics
154
+ // which calls saveLedger → acquireLock. acquireLock will retry for 100ms
155
+ // (lock file just sits there), then we remove it from a thread... but
156
+ // there are no threads in Node main.
157
+ //
158
+ // The correct approach is to spawn a background child that holds the lock
159
+ // for 100ms, and run acquireLock from the main process concurrently via
160
+ // snapshotUnitMetrics (which is synchronous). The child is started as a
161
+ // background process using spawn (not spawnSync), then we call
162
+ // snapshotUnitMetrics synchronously and block until it acquires or times out.
163
+
164
+ const child = spawn(process.execPath, ["-e", LOCK_HOLDER_WORKER], {
165
+ env: {
166
+ ...process.env,
167
+ GSD_TEST_LOCK_PATH: lp,
168
+ GSD_TEST_HOLD_MS: "100",
169
+ },
170
+ });
171
+
172
+ // Wait until the child has acquired the lock (it writes PID to stdout).
173
+ // Poll until the lock file exists (the child acquired it).
174
+ const waitStart = Date.now();
175
+ while (!existsSync(lp) && Date.now() - waitStart < 2000) {
176
+ // Busy-wait for child to acquire the lock — this is test setup, not
177
+ // production code. Short window (child acquires almost immediately).
178
+ const arr = new Int32Array(new SharedArrayBuffer(4));
179
+ Atomics.wait(arr, 0, 0, 5);
180
+ }
181
+
182
+ assert.ok(existsSync(lp), "child must have acquired the lock before we attempt");
183
+
184
+ // Now call snapshotUnitMetrics synchronously. It will call saveLedger →
185
+ // acquireLock, which retries with 5ms sleep until the child releases (~100ms).
186
+ initMetrics(tmpDir);
187
+ const ctx = assistantCtx();
188
+ const start = Date.now();
189
+ const unit = snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", Date.now() - 500, "test-model");
190
+ const elapsed = Date.now() - start;
191
+
192
+ // Clean up child.
193
+ child.kill();
194
+
195
+ // The operation must succeed (either by acquiring after child releases, or
196
+ // by timeout-fallback write in saveLedger).
197
+ assert.ok(unit !== null, "snapshotUnitMetrics must succeed under contention");
198
+
199
+ const retries = getLockSleepyRetries();
200
+
201
+ // With 5ms sleep and ~100ms contention, expect roughly 20 sleepy retries.
202
+ // Upper bound: 500ms timeout / 5ms = 100. With 2s default timeout, upper
203
+ // bound is 2000ms / 5ms = 400. But we want far fewer than the ~20,000
204
+ // that would occur without any sleep.
205
+ //
206
+ // Conservative assertion: fewer than 200 sleepy retries across the wait
207
+ // (vs ~20,000 without sleep). This is the key regression guard.
208
+ assert.ok(
209
+ retries < 200,
210
+ `Expected < 200 sleepy retries with 5ms sleep, got ${retries}. ` +
211
+ `Elapsed: ${elapsed}ms. LOCK_RETRY_INTERVAL_MS=${LOCK_RETRY_INTERVAL_MS}`,
212
+ );
213
+
214
+ // Sanity: at least a few retries happened (lock was actually contested).
215
+ assert.ok(
216
+ retries >= 1,
217
+ `Expected at least 1 sleepy retry (lock was held by child), got ${retries}`,
218
+ );
219
+ });
220
+
221
+ // ── Test 2: Stale-lock eviction does NOT sleep ────────────────────────────
222
+
223
+ test("stale-lock eviction path retries immediately without incrementing sleepy counter", () => {
224
+ const lp = lockPath(tmpDir);
225
+
226
+ // Create a stale lock (mtime older than STALE_LOCK_THRESHOLD_MS).
227
+ writeFileSync(lp, `999999\n${new Date(Date.now() - STALE_LOCK_THRESHOLD_MS - 500).toISOString()}\n`, "utf-8");
228
+ const staleTime = (Date.now() - STALE_LOCK_THRESHOLD_MS - 500) / 1000;
229
+ utimesSync(lp, staleTime, staleTime);
230
+
231
+ assert.ok(existsSync(lp), "stale lock file must exist before acquire");
232
+
233
+ initMetrics(tmpDir);
234
+ const ctx = assistantCtx();
235
+
236
+ const start = Date.now();
237
+ const unit = snapshotUnitMetrics(ctx, "execute-task", "M002/S01/T01", Date.now() - 200, "test-model");
238
+ const elapsed = Date.now() - start;
239
+
240
+ assert.ok(unit !== null, "snapshotUnitMetrics must succeed after stale-lock eviction");
241
+
242
+ const retries = getLockSleepyRetries();
243
+
244
+ // The stale-lock path uses `continue` (no sleep), so the sleepy retry
245
+ // counter must be zero: the lock was evicted and the very next openSync
246
+ // call succeeded — no sleepy retry was needed.
247
+ assert.equal(
248
+ retries,
249
+ 0,
250
+ `Expected 0 sleepy retries after stale-lock eviction, got ${retries}. ` +
251
+ `Elapsed: ${elapsed}ms`,
252
+ );
253
+
254
+ // Should complete very quickly (no artificial delay).
255
+ assert.ok(
256
+ elapsed < 200,
257
+ `Stale-lock recovery should complete quickly, took ${elapsed}ms`,
258
+ );
259
+ });
260
+
261
+ // ── Test 3: M3 regression — stale lock recovers + metrics written ─────────
262
+
263
+ test("M3 regression: stale lock from dead process is cleared and metrics are written", () => {
264
+ const lp = lockPath(tmpDir);
265
+
266
+ // Backdate the lock file to simulate a crashed process.
267
+ const stalePid = 9999999;
268
+ writeFileSync(
269
+ lp,
270
+ `${stalePid}\n${new Date(Date.now() - STALE_LOCK_THRESHOLD_MS - 1000).toISOString()}\n`,
271
+ "utf-8",
272
+ );
273
+ const staleTime = (Date.now() - STALE_LOCK_THRESHOLD_MS - 1000) / 1000;
274
+ utimesSync(lp, staleTime, staleTime);
275
+
276
+ initMetrics(tmpDir);
277
+ const ctx = assistantCtx();
278
+ const unit = snapshotUnitMetrics(ctx, "execute-task", "M003/S01/T01", Date.now() - 300, "test-model");
279
+
280
+ assert.ok(unit !== null, "snapshotUnitMetrics must succeed after stale lock from dead process");
281
+ assert.equal(unit!.id, "M003/S01/T01");
282
+ assert.ok(
283
+ existsSync(metricsPath(tmpDir)),
284
+ "metrics.json must exist after recovery",
285
+ );
286
+ });
287
+ });
@@ -0,0 +1,149 @@
1
+ // GSD-2 + metrics prune cache invalidation: pruned units must not reappear after snapshotUnitMetricsByScope
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
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+
15
+ import {
16
+ initMetricsByScope,
17
+ resetMetricsByScope,
18
+ snapshotUnitMetricsByScope,
19
+ pruneMetricsLedger,
20
+ type MetricsLedger,
21
+ } from "../metrics.js";
22
+ import { createWorkspace, scopeMilestone } from "../workspace.ts";
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function makeProjectDir(): string {
27
+ const dir = mkdtempSync(join(tmpdir(), "gsd-metrics-prune-"));
28
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
29
+ return dir;
30
+ }
31
+
32
+ function metricsPath(base: string): string {
33
+ return join(base, ".gsd", "metrics.json");
34
+ }
35
+
36
+ function makeUnit(id: string, startedAt: number): any {
37
+ return {
38
+ type: "execute-task",
39
+ id,
40
+ model: "test-model",
41
+ startedAt,
42
+ finishedAt: startedAt + 1000,
43
+ tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, total: 150 },
44
+ cost: 0.001,
45
+ toolCalls: 1,
46
+ assistantMessages: 1,
47
+ userMessages: 1,
48
+ };
49
+ }
50
+
51
+ function makeProjectLedger(units: any[]): MetricsLedger {
52
+ return { version: 1, projectStartedAt: 1000, units } as MetricsLedger;
53
+ }
54
+
55
+ function assistantCtx(): any {
56
+ const entries = [
57
+ {
58
+ type: "message",
59
+ id: "e0",
60
+ parentId: null,
61
+ timestamp: new Date().toISOString(),
62
+ message: {
63
+ role: "assistant",
64
+ content: [{ type: "text", text: "Done" }],
65
+ usage: {
66
+ input: 100,
67
+ output: 50,
68
+ cacheRead: 0,
69
+ cacheWrite: 0,
70
+ totalTokens: 150,
71
+ cost: 0.001,
72
+ },
73
+ },
74
+ },
75
+ ];
76
+ return { sessionManager: { getEntries: () => entries } };
77
+ }
78
+
79
+ // ─── Tests ───────────────────────────────────────────────────────────────────
80
+
81
+ describe("pruneMetricsLedger: invalidates scoped ledger cache", () => {
82
+ let tmpDir: string;
83
+ let ws: ReturnType<typeof createWorkspace>;
84
+ let scope: ReturnType<typeof scopeMilestone>;
85
+
86
+ beforeEach(() => {
87
+ tmpDir = makeProjectDir();
88
+ mkdirSync(join(tmpDir, ".gsd", "milestones"), { recursive: true });
89
+ ws = createWorkspace(tmpDir);
90
+ scope = scopeMilestone(ws, "M001");
91
+ });
92
+
93
+ afterEach(() => {
94
+ resetMetricsByScope(scope);
95
+ rmSync(tmpDir, { recursive: true, force: true });
96
+ });
97
+
98
+ test("pruned units do not reappear in subsequent snapshotUnitMetricsByScope call", () => {
99
+ // 1. Write a ledger with 5 units to disk.
100
+ const oldUnits = [
101
+ makeUnit("M001/S01/T01", 1000),
102
+ makeUnit("M001/S01/T02", 2000),
103
+ makeUnit("M001/S01/T03", 3000),
104
+ makeUnit("M001/S01/T04", 4000),
105
+ makeUnit("M001/S01/T05", 5000),
106
+ ];
107
+ writeFileSync(metricsPath(tmpDir), JSON.stringify(makeProjectLedger(oldUnits), null, 2));
108
+
109
+ // 2. Load the scoped cache — this populates scopedLedgers with all 5 units.
110
+ initMetricsByScope(scope);
111
+
112
+ // 3. Prune to keepCount=2 — should evict 3 old units from disk AND clear scopedLedgers.
113
+ const removed = pruneMetricsLedger(tmpDir, 2);
114
+ assert.equal(removed, 3, "pruneMetricsLedger should report 3 removed units");
115
+
116
+ // 4. Snapshot a new unit via scope. This exercises the lazy-reload path
117
+ // (scopedLedgers was cleared by prune) and writes the result to disk.
118
+ const ctx = assistantCtx();
119
+ const newUnit = snapshotUnitMetricsByScope(
120
+ scope,
121
+ ctx,
122
+ "execute-task",
123
+ "M001/S02/T01",
124
+ Date.now(),
125
+ "test-model",
126
+ );
127
+ assert.ok(newUnit !== null, "snapshotUnitMetricsByScope should return a unit");
128
+
129
+ // 5. Read the on-disk result and verify pruned units did NOT reappear.
130
+ const raw = readFileSync(metricsPath(tmpDir), "utf-8");
131
+ const ledger: MetricsLedger = JSON.parse(raw);
132
+
133
+ const unitIds = ledger.units.map((u) => u.id);
134
+
135
+ // The pruned units must not be in the output.
136
+ const prunedIds = ["M001/S01/T01", "M001/S01/T02", "M001/S01/T03"];
137
+ for (const id of prunedIds) {
138
+ assert.ok(
139
+ !unitIds.includes(id),
140
+ `pruned unit "${id}" must not reappear after prune + snapshot`,
141
+ );
142
+ }
143
+
144
+ // The 2 kept units and the new snapshot unit must be present.
145
+ assert.ok(unitIds.includes("M001/S01/T04"), "kept unit T04 should still be present");
146
+ assert.ok(unitIds.includes("M001/S01/T05"), "kept unit T05 should still be present");
147
+ assert.ok(unitIds.includes("M001/S02/T01"), "new snapshot unit should be present");
148
+ });
149
+ });