@tekyzinc/gsd-t 3.29.10 → 4.0.12
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/CHANGELOG.md +176 -0
- package/bin/gsd-t-parallel.cjs +64 -13
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +27 -3
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +6 -1
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +16 -1
- package/bin/gsd-t-test-data-ledger.cjs +1 -0
- package/bin/gsd-t.js +48 -376
- package/bin/journey-coverage.cjs +6 -3
- package/bin/parallel-cli.cjs +13 -1
- package/commands/gsd-t-debug.md +47 -533
- package/commands/gsd-t-design-decompose.md +24 -497
- package/commands/gsd-t-doc-ripple.md +23 -139
- package/commands/gsd-t-execute.md +41 -958
- package/commands/gsd-t-help.md +0 -2
- package/commands/gsd-t-impact.md +26 -295
- package/commands/gsd-t-integrate.md +44 -369
- package/commands/gsd-t-milestone.md +25 -124
- package/commands/gsd-t-partition.md +28 -539
- package/commands/gsd-t-plan.md +26 -472
- package/commands/gsd-t-prd.md +25 -311
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-resume.md +0 -33
- package/commands/gsd-t-verify.md +52 -569
- package/commands/gsd-t-wave.md +41 -430
- package/package.json +1 -1
- package/scripts/gsd-t-calibration-hook.js +3 -1
- package/scripts/hooks/pre-commit-journey-coverage +0 -2
- package/scripts/hooks/pre-commit-playwright-gate +0 -2
- package/templates/CLAUDE-global.md +43 -173
- package/templates/CLAUDE-project.md +118 -104
- package/templates/prompts/design-verify-subagent.md +3 -0
- package/templates/prompts/qa-subagent.md +3 -0
- package/templates/prompts/red-team-subagent.md +5 -2
- package/templates/workflows/_lib.js +176 -0
- package/templates/workflows/gsd-t-debug.workflow.js +86 -0
- package/templates/workflows/gsd-t-execute.workflow.js +206 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
- package/templates/workflows/gsd-t-phase.workflow.js +94 -0
- package/templates/workflows/gsd-t-quick.workflow.js +72 -0
- package/templates/workflows/gsd-t-verify.workflow.js +348 -0
- package/templates/workflows/gsd-t-wave.workflow.js +43 -0
- package/bin/check-headless-sessions.js +0 -140
- package/bin/context-budget-audit.cjs +0 -447
- package/bin/context-meter-config.cjs +0 -101
- package/bin/context-meter-config.test.cjs +0 -101
- package/bin/event-stream.cjs +0 -205
- package/bin/gsd-t-benchmark-orchestrator.js +0 -437
- package/bin/gsd-t-capture-lint.cjs +0 -440
- package/bin/gsd-t-economics.cjs +0 -315
- package/bin/gsd-t-in-session-usage.cjs +0 -213
- package/bin/gsd-t-orchestrator-config.cjs +0 -161
- package/bin/gsd-t-orchestrator-queue.cjs +0 -180
- package/bin/gsd-t-orchestrator-recover.cjs +0 -231
- package/bin/gsd-t-orchestrator-worker.cjs +0 -219
- package/bin/gsd-t-orchestrator.js +0 -535
- package/bin/gsd-t-parallel-probe.cjs +0 -132
- package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
- package/bin/gsd-t-ratelimit-probe.cjs +0 -648
- package/bin/gsd-t-report-tokens.cjs +0 -549
- package/bin/gsd-t-stream-feed-client.cjs +0 -151
- package/bin/gsd-t-token-backfill.cjs +0 -366
- package/bin/gsd-t-token-capture.cjs +0 -321
- package/bin/gsd-t-token-dashboard.cjs +0 -353
- package/bin/gsd-t-token-regenerate-log.cjs +0 -129
- package/bin/gsd-t-tool-attribution.cjs +0 -377
- package/bin/gsd-t-tool-cost.cjs +0 -195
- package/bin/gsd-t-transcript-tee.cjs +0 -246
- package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
- package/bin/gsd-t-unattended-platform.cjs +0 -551
- package/bin/gsd-t-unattended-platform.js +0 -381
- package/bin/gsd-t-unattended-safety.cjs +0 -773
- package/bin/gsd-t-unattended-safety.js +0 -788
- package/bin/gsd-t-unattended.cjs +0 -2109
- package/bin/gsd-t-unattended.js +0 -1367
- package/bin/gsd-t-worker-dispatch.cjs +0 -211
- package/bin/handoff-lock.cjs +0 -249
- package/bin/handoff-lock.js +0 -249
- package/bin/headless-auto-spawn.cjs +0 -711
- package/bin/headless-auto-spawn.js +0 -373
- package/bin/headless-exit-codes.cjs +0 -67
- package/bin/live-activity-report.cjs +0 -615
- package/bin/log-tail.cjs +0 -81
- package/bin/m44-proof-measure.cjs +0 -285
- package/bin/m46-iter-proof.cjs +0 -149
- package/bin/m46-worker-proof.cjs +0 -201
- package/bin/m55-substrate-proof.cjs +0 -134
- package/bin/metrics-rollup.js +0 -200
- package/bin/model-windows.cjs +0 -99
- package/bin/model-windows.test.cjs +0 -75
- package/bin/parallelism-report.cjs +0 -535
- package/bin/runway-estimator.cjs +0 -242
- package/bin/spawn-plan-derive.cjs +0 -163
- package/bin/spawn-plan-status-updater.cjs +0 -292
- package/bin/spawn-plan-writer.cjs +0 -204
- package/bin/supervisor-pid-fingerprint.cjs +0 -126
- package/bin/token-budget.cjs +0 -265
- package/bin/unattended-watch-format.cjs +0 -178
- package/bin/watch-progress.js +0 -155
- package/commands/gsd-t-unattended-stop.md +0 -85
- package/commands/gsd-t-unattended-watch.md +0 -465
- package/commands/gsd-t-unattended.md +0 -476
- package/commands/gsd-t-visualize.md +0 -116
- package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
- package/scripts/gsd-t-context-meter.js +0 -341
- package/scripts/gsd-t-context-meter.test.js +0 -471
- package/scripts/gsd-t-dashboard-server.js +0 -1203
- package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
- package/scripts/gsd-t-transcript.html +0 -1821
- package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
- package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
- package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
- package/templates/hooks/post-commit-spawn-plan.sh +0 -85
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* GSD-T Worker Sub-Dispatch (M46 D2 T2)
|
|
6
|
-
*
|
|
7
|
-
* Thin adapter that lets an unattended supervisor worker fan out its own
|
|
8
|
-
* file-disjoint tasks by reusing the M44-verified `runDispatch` instrument.
|
|
9
|
-
* This module is a new CONSUMER of `bin/gsd-t-parallel.cjs::runDispatch` —
|
|
10
|
-
* not a modifier. The in-session dispatch path is byte-identical post-D2.
|
|
11
|
-
*
|
|
12
|
-
* Contract: .gsd-t/contracts/headless-default-contract.md v2.1.0 §Worker Sub-Dispatch
|
|
13
|
-
*
|
|
14
|
-
* Public API:
|
|
15
|
-
* dispatchWorkerTasks({projectDir, parentSessionId, tasks, maxParallel})
|
|
16
|
-
* → { parallel, taskResults, wallClockMs, reason }
|
|
17
|
-
*
|
|
18
|
-
* Triggers sub-dispatch when all hold:
|
|
19
|
-
* - tasks.length > 1
|
|
20
|
-
* - tasks are file-disjoint (pairwise no overlap on `task.files`)
|
|
21
|
-
* Otherwise returns `{parallel: false, …}` and the caller falls through to
|
|
22
|
-
* its current serial behavior.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const path = require('path');
|
|
26
|
-
|
|
27
|
-
const SPAWN_PLAN_KIND = 'unattended-worker-sub';
|
|
28
|
-
const DEFAULT_MAX_PARALLEL = 4;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Pairwise file-disjointness across a task set. Returns true iff no two
|
|
32
|
-
* tasks share any file in their `files` arrays. Tasks without a `files`
|
|
33
|
-
* array (or empty) are treated as having no declared file scope and
|
|
34
|
-
* therefore never overlap with anyone — callers upstream should not
|
|
35
|
-
* pass such tasks to sub-dispatch, but we remain conservative: a task
|
|
36
|
-
* with no `files` is considered disjoint from every other task only
|
|
37
|
-
* when every counterpart also has files declared. When both sides lack
|
|
38
|
-
* `files`, we conservatively report NOT disjoint so the caller falls
|
|
39
|
-
* back to serial — an undeclared scope is an unknown scope.
|
|
40
|
-
*/
|
|
41
|
-
function _areFileDisjoint(tasks) {
|
|
42
|
-
if (!Array.isArray(tasks) || tasks.length < 2) return true;
|
|
43
|
-
for (let i = 0; i < tasks.length; i++) {
|
|
44
|
-
const a = tasks[i];
|
|
45
|
-
const aFiles = (a && Array.isArray(a.files)) ? a.files : null;
|
|
46
|
-
for (let j = i + 1; j < tasks.length; j++) {
|
|
47
|
-
const b = tasks[j];
|
|
48
|
-
const bFiles = (b && Array.isArray(b.files)) ? b.files : null;
|
|
49
|
-
if (!aFiles || !bFiles) return false;
|
|
50
|
-
const set = new Set(aFiles);
|
|
51
|
-
for (const f of bFiles) {
|
|
52
|
-
if (set.has(f)) return false;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Emit a spawn-plan frame for this sub-dispatch. Best-effort; writer
|
|
61
|
-
* failures never propagate (per spawn-plan-writer.cjs §Hard rules).
|
|
62
|
-
*/
|
|
63
|
-
function _writeSubDispatchSpawnPlan({ projectDir, parentSessionId, tasks }) {
|
|
64
|
-
try {
|
|
65
|
-
const writer = require(path.join(__dirname, 'spawn-plan-writer.cjs'));
|
|
66
|
-
const spawnId = `worker-sub-${parentSessionId}-${Date.now()}`;
|
|
67
|
-
const planTasks = tasks.map((t) => ({
|
|
68
|
-
id: (t && typeof t.taskId === 'string') ? t.taskId : String((t && t.taskId) || ''),
|
|
69
|
-
title: (t && typeof t.title === 'string') ? t.title : '',
|
|
70
|
-
status: 'pending',
|
|
71
|
-
}));
|
|
72
|
-
writer.writeSpawnPlan({
|
|
73
|
-
spawnId,
|
|
74
|
-
kind: SPAWN_PLAN_KIND,
|
|
75
|
-
projectDir,
|
|
76
|
-
tasks: planTasks,
|
|
77
|
-
});
|
|
78
|
-
} catch (_e) {
|
|
79
|
-
/* best-effort; never block dispatch */
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* dispatchWorkerTasks — the M46 D2 sub-dispatch entry point.
|
|
85
|
-
*
|
|
86
|
-
* @param {object} opts
|
|
87
|
-
* @param {string} opts.projectDir absolute project root
|
|
88
|
-
* @param {string} opts.parentSessionId $GSD_T_PARENT_AGENT_ID from worker env
|
|
89
|
-
* @param {Array} opts.tasks [{taskId, files, command, ...}]
|
|
90
|
-
* @param {number} [opts.maxParallel=4] concurrency cap (default matches M44)
|
|
91
|
-
* @returns {Promise<{parallel: boolean, taskResults: Array, wallClockMs: number, reason: string}>}
|
|
92
|
-
*/
|
|
93
|
-
async function dispatchWorkerTasks(opts) {
|
|
94
|
-
const projectDir = (opts && opts.projectDir) || process.cwd();
|
|
95
|
-
const parentSessionId = (opts && opts.parentSessionId) || '';
|
|
96
|
-
const tasks = (opts && Array.isArray(opts.tasks)) ? opts.tasks : [];
|
|
97
|
-
const maxParallel = Number.isFinite(opts && opts.maxParallel) && opts.maxParallel > 0
|
|
98
|
-
? Math.floor(opts.maxParallel)
|
|
99
|
-
: DEFAULT_MAX_PARALLEL;
|
|
100
|
-
|
|
101
|
-
if (tasks.length === 0) {
|
|
102
|
-
return { parallel: false, taskResults: [], wallClockMs: 0, reason: 'no-tasks' };
|
|
103
|
-
}
|
|
104
|
-
if (tasks.length === 1) {
|
|
105
|
-
return { parallel: false, taskResults: [], wallClockMs: 0, reason: 'single-task' };
|
|
106
|
-
}
|
|
107
|
-
if (!_areFileDisjoint(tasks)) {
|
|
108
|
-
return { parallel: false, taskResults: [], wallClockMs: 0, reason: 'file-overlap' };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
_writeSubDispatchSpawnPlan({ projectDir, parentSessionId, tasks });
|
|
112
|
-
|
|
113
|
-
const startedAt = Date.now();
|
|
114
|
-
try {
|
|
115
|
-
const parallel = require(path.join(__dirname, 'gsd-t-parallel.cjs'));
|
|
116
|
-
const result = await parallel.runDispatch({
|
|
117
|
-
projectDir,
|
|
118
|
-
tasks,
|
|
119
|
-
maxWorkers: maxParallel,
|
|
120
|
-
mode: 'worker-subdispatch',
|
|
121
|
-
});
|
|
122
|
-
const wallClockMs = Date.now() - startedAt;
|
|
123
|
-
const taskResults = (result && Array.isArray(result.workerResults))
|
|
124
|
-
? result.workerResults
|
|
125
|
-
: (result && Array.isArray(result.taskResults) ? result.taskResults : []);
|
|
126
|
-
return {
|
|
127
|
-
parallel: true,
|
|
128
|
-
taskResults,
|
|
129
|
-
wallClockMs,
|
|
130
|
-
reason: 'dispatched',
|
|
131
|
-
};
|
|
132
|
-
} catch (e) {
|
|
133
|
-
const wallClockMs = Date.now() - startedAt;
|
|
134
|
-
const msg = (e && e.message) ? e.message : String(e);
|
|
135
|
-
return {
|
|
136
|
-
parallel: false,
|
|
137
|
-
taskResults: [],
|
|
138
|
-
wallClockMs,
|
|
139
|
-
reason: `dispatch-error: ${msg}`,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
module.exports = {
|
|
145
|
-
dispatchWorkerTasks,
|
|
146
|
-
_areFileDisjoint,
|
|
147
|
-
SPAWN_PLAN_KIND,
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
if (require.main === module) {
|
|
151
|
-
(async () => {
|
|
152
|
-
const fs = require('fs');
|
|
153
|
-
const argv = process.argv.slice(2);
|
|
154
|
-
let parentSessionId = null;
|
|
155
|
-
let tasksPath = null;
|
|
156
|
-
let maxParallel = DEFAULT_MAX_PARALLEL;
|
|
157
|
-
for (let i = 0; i < argv.length; i++) {
|
|
158
|
-
const a = argv[i];
|
|
159
|
-
if (a === '--parent-session') {
|
|
160
|
-
parentSessionId = argv[++i];
|
|
161
|
-
} else if (a === '--tasks') {
|
|
162
|
-
tasksPath = argv[++i];
|
|
163
|
-
} else if (a === '--max-parallel') {
|
|
164
|
-
const n = parseInt(argv[++i], 10);
|
|
165
|
-
if (Number.isFinite(n) && n > 0) maxParallel = n;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (!parentSessionId) {
|
|
169
|
-
process.stderr.write('error: --parent-session required\n');
|
|
170
|
-
process.exit(2);
|
|
171
|
-
}
|
|
172
|
-
if (!tasksPath) {
|
|
173
|
-
process.stderr.write('error: --tasks required\n');
|
|
174
|
-
process.exit(2);
|
|
175
|
-
}
|
|
176
|
-
let raw;
|
|
177
|
-
try {
|
|
178
|
-
raw = fs.readFileSync(tasksPath, 'utf8');
|
|
179
|
-
} catch (e) {
|
|
180
|
-
process.stderr.write(`error: cannot read tasks file ${tasksPath}: ${(e && e.message) || e}\n`);
|
|
181
|
-
process.exit(2);
|
|
182
|
-
}
|
|
183
|
-
let tasks;
|
|
184
|
-
try {
|
|
185
|
-
tasks = JSON.parse(raw);
|
|
186
|
-
} catch (e) {
|
|
187
|
-
process.stderr.write(`error: malformed tasks JSON: ${(e && e.message) || e}\n`);
|
|
188
|
-
process.exit(2);
|
|
189
|
-
}
|
|
190
|
-
if (!Array.isArray(tasks)) {
|
|
191
|
-
process.stderr.write('error: tasks JSON must be an array\n');
|
|
192
|
-
process.exit(2);
|
|
193
|
-
}
|
|
194
|
-
const projectDir = process.cwd();
|
|
195
|
-
try {
|
|
196
|
-
const result = await dispatchWorkerTasks({
|
|
197
|
-
projectDir,
|
|
198
|
-
parentSessionId,
|
|
199
|
-
tasks,
|
|
200
|
-
maxParallel,
|
|
201
|
-
});
|
|
202
|
-
process.stdout.write(JSON.stringify(result) + '\n');
|
|
203
|
-
const anyFailed = Array.isArray(result && result.taskResults)
|
|
204
|
-
&& result.taskResults.some((r) => r && (r.exitCode !== 0 && r.exitCode != null));
|
|
205
|
-
process.exit(anyFailed ? 1 : 0);
|
|
206
|
-
} catch (e) {
|
|
207
|
-
process.stderr.write(`error: dispatch threw: ${(e && e.message) || e}\n`);
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
})();
|
|
211
|
-
}
|
package/bin/handoff-lock.cjs
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* GSD-T Handoff Lock — Fail-safe sentinel preventing parent/child race
|
|
5
|
-
* conditions in `autoSpawnHeadless()`.
|
|
6
|
-
*
|
|
7
|
-
* Problem: when the interactive parent spawns a detached headless child to
|
|
8
|
-
* resume a session, both processes may briefly contend for the continue-here
|
|
9
|
-
* file and session JSON. If the child wakes and reads before the parent has
|
|
10
|
-
* finished writing, it sees stale or partial state.
|
|
11
|
-
*
|
|
12
|
-
* Solution: the parent acquires a short-lived lock on
|
|
13
|
-
* `.gsd-t/.handoff/lock-{sessionId}` BEFORE writing handoff artifacts and
|
|
14
|
-
* releases it AFTER `child.unref()` returns. The child waits on the same
|
|
15
|
-
* lock path before reading the continue-here file.
|
|
16
|
-
*
|
|
17
|
-
* Locks are TTL-bounded (default 30s) so a crashed parent can never wedge
|
|
18
|
-
* a future spawn — `cleanStaleLocks()` swept by housekeeping or by the next
|
|
19
|
-
* acquire attempt against an expired record reclaims the slot.
|
|
20
|
-
*
|
|
21
|
-
* Zero external dependencies (Node.js built-ins only).
|
|
22
|
-
*
|
|
23
|
-
* Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
|
|
24
|
-
* (implementation-detail primitive; no contract bump)
|
|
25
|
-
* Consumers: bin/headless-auto-spawn.js (Task 2 — wires this in),
|
|
26
|
-
* commands/gsd-t-resume.md (child-side wait).
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const fs = require("fs");
|
|
30
|
-
const path = require("path");
|
|
31
|
-
|
|
32
|
-
// ── Constants ────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
const HANDOFF_DIR_REL = path.join(".gsd-t", ".handoff");
|
|
35
|
-
const LOCK_FILE_PREFIX = "lock-";
|
|
36
|
-
const DEFAULT_TTL_MS = 30000;
|
|
37
|
-
const DEFAULT_WAIT_TIMEOUT_MS = 30000;
|
|
38
|
-
const DEFAULT_STALE_AGE_MS = 60000;
|
|
39
|
-
const POLL_INTERVAL_MS = 100;
|
|
40
|
-
const GRACE_MS = 500;
|
|
41
|
-
|
|
42
|
-
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
module.exports = {
|
|
45
|
-
acquireHandoffLock,
|
|
46
|
-
releaseHandoffLock,
|
|
47
|
-
waitForLockRelease,
|
|
48
|
-
cleanStaleLocks,
|
|
49
|
-
// Exported for tests / consumer wiring:
|
|
50
|
-
lockPathFor,
|
|
51
|
-
HANDOFF_DIR_REL,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// ── acquireHandoffLock ───────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Acquire an exclusive handoff lock for the given sessionId.
|
|
58
|
-
*
|
|
59
|
-
* @param {string} projectDir
|
|
60
|
-
* @param {string} sessionId
|
|
61
|
-
* @param {{ ttlMs?: number }} [opts]
|
|
62
|
-
* @returns {{ lockPath: string, sessionId: string }} release handle
|
|
63
|
-
* @throws {Error} if an unexpired lock already exists
|
|
64
|
-
*/
|
|
65
|
-
function acquireHandoffLock(projectDir, sessionId, opts) {
|
|
66
|
-
if (!projectDir || typeof projectDir !== "string") {
|
|
67
|
-
throw new Error("acquireHandoffLock: `projectDir` is required");
|
|
68
|
-
}
|
|
69
|
-
if (!sessionId || typeof sessionId !== "string") {
|
|
70
|
-
throw new Error("acquireHandoffLock: `sessionId` is required");
|
|
71
|
-
}
|
|
72
|
-
const ttlMs = (opts && opts.ttlMs) || DEFAULT_TTL_MS;
|
|
73
|
-
|
|
74
|
-
const dir = path.join(projectDir, HANDOFF_DIR_REL);
|
|
75
|
-
ensureDir(dir);
|
|
76
|
-
|
|
77
|
-
const lockPath = lockPathFor(projectDir, sessionId);
|
|
78
|
-
|
|
79
|
-
// If a lock file already exists, decide whether it is still binding.
|
|
80
|
-
if (fs.existsSync(lockPath)) {
|
|
81
|
-
const existing = readLockSafe(lockPath);
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
if (existing && now < existing.releaseBy + GRACE_MS) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`handoff lock held by PID ${existing.parentPid} until ${new Date(
|
|
86
|
-
existing.releaseBy,
|
|
87
|
-
).toISOString()}`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
// Expired or unparseable — reclaim it.
|
|
91
|
-
try {
|
|
92
|
-
fs.unlinkSync(lockPath);
|
|
93
|
-
} catch (_) {
|
|
94
|
-
/* race: someone else cleaned it; fall through */
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const acquiredAt = Date.now();
|
|
99
|
-
const record = {
|
|
100
|
-
sessionId,
|
|
101
|
-
parentPid: process.pid,
|
|
102
|
-
acquiredAt,
|
|
103
|
-
releaseBy: acquiredAt + ttlMs,
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// Write atomically: O_EXCL ensures we lose if a concurrent acquirer beats
|
|
107
|
-
// us between the existsSync check above and this write.
|
|
108
|
-
let fd;
|
|
109
|
-
try {
|
|
110
|
-
fd = fs.openSync(lockPath, "wx");
|
|
111
|
-
} catch (e) {
|
|
112
|
-
if (e && e.code === "EEXIST") {
|
|
113
|
-
// Another acquirer raced ahead; surface a uniform error.
|
|
114
|
-
const existing = readLockSafe(lockPath);
|
|
115
|
-
const pid = existing ? existing.parentPid : "unknown";
|
|
116
|
-
const until = existing
|
|
117
|
-
? new Date(existing.releaseBy).toISOString()
|
|
118
|
-
: "unknown";
|
|
119
|
-
throw new Error(`handoff lock held by PID ${pid} until ${until}`);
|
|
120
|
-
}
|
|
121
|
-
throw e;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
|
|
125
|
-
} finally {
|
|
126
|
-
fs.closeSync(fd);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return { lockPath, sessionId };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── releaseHandoffLock ───────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Release a previously acquired handoff lock. Idempotent — tolerates a
|
|
136
|
-
* missing file (already released or never existed).
|
|
137
|
-
*
|
|
138
|
-
* @param {{ lockPath: string, sessionId: string }} handle
|
|
139
|
-
*/
|
|
140
|
-
function releaseHandoffLock(handle) {
|
|
141
|
-
if (!handle || !handle.lockPath) return;
|
|
142
|
-
try {
|
|
143
|
-
fs.unlinkSync(handle.lockPath);
|
|
144
|
-
} catch (e) {
|
|
145
|
-
if (e && e.code === "ENOENT") return; // already released
|
|
146
|
-
throw e;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── waitForLockRelease ───────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Poll until the lock file for {sessionId} no longer exists, OR until
|
|
154
|
-
* timeoutMs elapses. Throws on timeout.
|
|
155
|
-
*
|
|
156
|
-
* @param {string} projectDir
|
|
157
|
-
* @param {string} sessionId
|
|
158
|
-
* @param {number} [timeoutMs]
|
|
159
|
-
* @returns {Promise<true>}
|
|
160
|
-
*/
|
|
161
|
-
function waitForLockRelease(projectDir, sessionId, timeoutMs) {
|
|
162
|
-
const limit = typeof timeoutMs === "number" ? timeoutMs : DEFAULT_WAIT_TIMEOUT_MS;
|
|
163
|
-
const lockPath = lockPathFor(projectDir, sessionId);
|
|
164
|
-
const start = Date.now();
|
|
165
|
-
|
|
166
|
-
return new Promise((resolve, reject) => {
|
|
167
|
-
const check = () => {
|
|
168
|
-
if (!fs.existsSync(lockPath)) {
|
|
169
|
-
resolve(true);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (Date.now() - start >= limit) {
|
|
173
|
-
reject(new Error(`waitForLockRelease timeout after ${limit}ms`));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
setTimeout(check, POLL_INTERVAL_MS);
|
|
177
|
-
};
|
|
178
|
-
check();
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── cleanStaleLocks ──────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Sweep `.gsd-t/.handoff/` and remove any `lock-*` file whose `acquiredAt`
|
|
186
|
-
* is older than `maxAgeMs`. Returns the count of files removed.
|
|
187
|
-
*
|
|
188
|
-
* @param {string} projectDir
|
|
189
|
-
* @param {number} [maxAgeMs]
|
|
190
|
-
* @returns {number}
|
|
191
|
-
*/
|
|
192
|
-
function cleanStaleLocks(projectDir, maxAgeMs) {
|
|
193
|
-
const limit = typeof maxAgeMs === "number" ? maxAgeMs : DEFAULT_STALE_AGE_MS;
|
|
194
|
-
const dir = path.join(projectDir, HANDOFF_DIR_REL);
|
|
195
|
-
if (!fs.existsSync(dir)) return 0;
|
|
196
|
-
|
|
197
|
-
let removed = 0;
|
|
198
|
-
let entries;
|
|
199
|
-
try {
|
|
200
|
-
entries = fs.readdirSync(dir);
|
|
201
|
-
} catch (_) {
|
|
202
|
-
return 0;
|
|
203
|
-
}
|
|
204
|
-
const now = Date.now();
|
|
205
|
-
for (const name of entries) {
|
|
206
|
-
if (!name.startsWith(LOCK_FILE_PREFIX)) continue;
|
|
207
|
-
const fp = path.join(dir, name);
|
|
208
|
-
const rec = readLockSafe(fp);
|
|
209
|
-
// Unparseable lock files are treated as stale — they're not protecting
|
|
210
|
-
// anything, and leaving them around defeats the cleaner.
|
|
211
|
-
if (!rec || now - rec.acquiredAt > limit) {
|
|
212
|
-
try {
|
|
213
|
-
fs.unlinkSync(fp);
|
|
214
|
-
removed++;
|
|
215
|
-
} catch (_) {
|
|
216
|
-
/* ignore */
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return removed;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
224
|
-
|
|
225
|
-
function lockPathFor(projectDir, sessionId) {
|
|
226
|
-
return path.join(projectDir, HANDOFF_DIR_REL, `${LOCK_FILE_PREFIX}${sessionId}`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function readLockSafe(fp) {
|
|
230
|
-
try {
|
|
231
|
-
const raw = fs.readFileSync(fp, "utf8");
|
|
232
|
-
const j = JSON.parse(raw);
|
|
233
|
-
if (
|
|
234
|
-
typeof j === "object" &&
|
|
235
|
-
j &&
|
|
236
|
-
typeof j.acquiredAt === "number" &&
|
|
237
|
-
typeof j.releaseBy === "number"
|
|
238
|
-
) {
|
|
239
|
-
return j;
|
|
240
|
-
}
|
|
241
|
-
return null;
|
|
242
|
-
} catch (_) {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function ensureDir(d) {
|
|
248
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
249
|
-
}
|
package/bin/handoff-lock.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* GSD-T Handoff Lock — Fail-safe sentinel preventing parent/child race
|
|
5
|
-
* conditions in `autoSpawnHeadless()`.
|
|
6
|
-
*
|
|
7
|
-
* Problem: when the interactive parent spawns a detached headless child to
|
|
8
|
-
* resume a session, both processes may briefly contend for the continue-here
|
|
9
|
-
* file and session JSON. If the child wakes and reads before the parent has
|
|
10
|
-
* finished writing, it sees stale or partial state.
|
|
11
|
-
*
|
|
12
|
-
* Solution: the parent acquires a short-lived lock on
|
|
13
|
-
* `.gsd-t/.handoff/lock-{sessionId}` BEFORE writing handoff artifacts and
|
|
14
|
-
* releases it AFTER `child.unref()` returns. The child waits on the same
|
|
15
|
-
* lock path before reading the continue-here file.
|
|
16
|
-
*
|
|
17
|
-
* Locks are TTL-bounded (default 30s) so a crashed parent can never wedge
|
|
18
|
-
* a future spawn — `cleanStaleLocks()` swept by housekeeping or by the next
|
|
19
|
-
* acquire attempt against an expired record reclaims the slot.
|
|
20
|
-
*
|
|
21
|
-
* Zero external dependencies (Node.js built-ins only).
|
|
22
|
-
*
|
|
23
|
-
* Contract: .gsd-t/contracts/headless-auto-spawn-contract.md v1.0.0
|
|
24
|
-
* (implementation-detail primitive; no contract bump)
|
|
25
|
-
* Consumers: bin/headless-auto-spawn.js (Task 2 — wires this in),
|
|
26
|
-
* commands/gsd-t-resume.md (child-side wait).
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const fs = require("fs");
|
|
30
|
-
const path = require("path");
|
|
31
|
-
|
|
32
|
-
// ── Constants ────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
const HANDOFF_DIR_REL = path.join(".gsd-t", ".handoff");
|
|
35
|
-
const LOCK_FILE_PREFIX = "lock-";
|
|
36
|
-
const DEFAULT_TTL_MS = 30000;
|
|
37
|
-
const DEFAULT_WAIT_TIMEOUT_MS = 30000;
|
|
38
|
-
const DEFAULT_STALE_AGE_MS = 60000;
|
|
39
|
-
const POLL_INTERVAL_MS = 100;
|
|
40
|
-
const GRACE_MS = 500;
|
|
41
|
-
|
|
42
|
-
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
module.exports = {
|
|
45
|
-
acquireHandoffLock,
|
|
46
|
-
releaseHandoffLock,
|
|
47
|
-
waitForLockRelease,
|
|
48
|
-
cleanStaleLocks,
|
|
49
|
-
// Exported for tests / consumer wiring:
|
|
50
|
-
lockPathFor,
|
|
51
|
-
HANDOFF_DIR_REL,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// ── acquireHandoffLock ───────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Acquire an exclusive handoff lock for the given sessionId.
|
|
58
|
-
*
|
|
59
|
-
* @param {string} projectDir
|
|
60
|
-
* @param {string} sessionId
|
|
61
|
-
* @param {{ ttlMs?: number }} [opts]
|
|
62
|
-
* @returns {{ lockPath: string, sessionId: string }} release handle
|
|
63
|
-
* @throws {Error} if an unexpired lock already exists
|
|
64
|
-
*/
|
|
65
|
-
function acquireHandoffLock(projectDir, sessionId, opts) {
|
|
66
|
-
if (!projectDir || typeof projectDir !== "string") {
|
|
67
|
-
throw new Error("acquireHandoffLock: `projectDir` is required");
|
|
68
|
-
}
|
|
69
|
-
if (!sessionId || typeof sessionId !== "string") {
|
|
70
|
-
throw new Error("acquireHandoffLock: `sessionId` is required");
|
|
71
|
-
}
|
|
72
|
-
const ttlMs = (opts && opts.ttlMs) || DEFAULT_TTL_MS;
|
|
73
|
-
|
|
74
|
-
const dir = path.join(projectDir, HANDOFF_DIR_REL);
|
|
75
|
-
ensureDir(dir);
|
|
76
|
-
|
|
77
|
-
const lockPath = lockPathFor(projectDir, sessionId);
|
|
78
|
-
|
|
79
|
-
// If a lock file already exists, decide whether it is still binding.
|
|
80
|
-
if (fs.existsSync(lockPath)) {
|
|
81
|
-
const existing = readLockSafe(lockPath);
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
if (existing && now < existing.releaseBy + GRACE_MS) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`handoff lock held by PID ${existing.parentPid} until ${new Date(
|
|
86
|
-
existing.releaseBy,
|
|
87
|
-
).toISOString()}`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
// Expired or unparseable — reclaim it.
|
|
91
|
-
try {
|
|
92
|
-
fs.unlinkSync(lockPath);
|
|
93
|
-
} catch (_) {
|
|
94
|
-
/* race: someone else cleaned it; fall through */
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const acquiredAt = Date.now();
|
|
99
|
-
const record = {
|
|
100
|
-
sessionId,
|
|
101
|
-
parentPid: process.pid,
|
|
102
|
-
acquiredAt,
|
|
103
|
-
releaseBy: acquiredAt + ttlMs,
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// Write atomically: O_EXCL ensures we lose if a concurrent acquirer beats
|
|
107
|
-
// us between the existsSync check above and this write.
|
|
108
|
-
let fd;
|
|
109
|
-
try {
|
|
110
|
-
fd = fs.openSync(lockPath, "wx");
|
|
111
|
-
} catch (e) {
|
|
112
|
-
if (e && e.code === "EEXIST") {
|
|
113
|
-
// Another acquirer raced ahead; surface a uniform error.
|
|
114
|
-
const existing = readLockSafe(lockPath);
|
|
115
|
-
const pid = existing ? existing.parentPid : "unknown";
|
|
116
|
-
const until = existing
|
|
117
|
-
? new Date(existing.releaseBy).toISOString()
|
|
118
|
-
: "unknown";
|
|
119
|
-
throw new Error(`handoff lock held by PID ${pid} until ${until}`);
|
|
120
|
-
}
|
|
121
|
-
throw e;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
|
|
125
|
-
} finally {
|
|
126
|
-
fs.closeSync(fd);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return { lockPath, sessionId };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── releaseHandoffLock ───────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Release a previously acquired handoff lock. Idempotent — tolerates a
|
|
136
|
-
* missing file (already released or never existed).
|
|
137
|
-
*
|
|
138
|
-
* @param {{ lockPath: string, sessionId: string }} handle
|
|
139
|
-
*/
|
|
140
|
-
function releaseHandoffLock(handle) {
|
|
141
|
-
if (!handle || !handle.lockPath) return;
|
|
142
|
-
try {
|
|
143
|
-
fs.unlinkSync(handle.lockPath);
|
|
144
|
-
} catch (e) {
|
|
145
|
-
if (e && e.code === "ENOENT") return; // already released
|
|
146
|
-
throw e;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── waitForLockRelease ───────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Poll until the lock file for {sessionId} no longer exists, OR until
|
|
154
|
-
* timeoutMs elapses. Throws on timeout.
|
|
155
|
-
*
|
|
156
|
-
* @param {string} projectDir
|
|
157
|
-
* @param {string} sessionId
|
|
158
|
-
* @param {number} [timeoutMs]
|
|
159
|
-
* @returns {Promise<true>}
|
|
160
|
-
*/
|
|
161
|
-
function waitForLockRelease(projectDir, sessionId, timeoutMs) {
|
|
162
|
-
const limit = typeof timeoutMs === "number" ? timeoutMs : DEFAULT_WAIT_TIMEOUT_MS;
|
|
163
|
-
const lockPath = lockPathFor(projectDir, sessionId);
|
|
164
|
-
const start = Date.now();
|
|
165
|
-
|
|
166
|
-
return new Promise((resolve, reject) => {
|
|
167
|
-
const check = () => {
|
|
168
|
-
if (!fs.existsSync(lockPath)) {
|
|
169
|
-
resolve(true);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (Date.now() - start >= limit) {
|
|
173
|
-
reject(new Error(`waitForLockRelease timeout after ${limit}ms`));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
setTimeout(check, POLL_INTERVAL_MS);
|
|
177
|
-
};
|
|
178
|
-
check();
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ── cleanStaleLocks ──────────────────────────────────────────────────────────
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Sweep `.gsd-t/.handoff/` and remove any `lock-*` file whose `acquiredAt`
|
|
186
|
-
* is older than `maxAgeMs`. Returns the count of files removed.
|
|
187
|
-
*
|
|
188
|
-
* @param {string} projectDir
|
|
189
|
-
* @param {number} [maxAgeMs]
|
|
190
|
-
* @returns {number}
|
|
191
|
-
*/
|
|
192
|
-
function cleanStaleLocks(projectDir, maxAgeMs) {
|
|
193
|
-
const limit = typeof maxAgeMs === "number" ? maxAgeMs : DEFAULT_STALE_AGE_MS;
|
|
194
|
-
const dir = path.join(projectDir, HANDOFF_DIR_REL);
|
|
195
|
-
if (!fs.existsSync(dir)) return 0;
|
|
196
|
-
|
|
197
|
-
let removed = 0;
|
|
198
|
-
let entries;
|
|
199
|
-
try {
|
|
200
|
-
entries = fs.readdirSync(dir);
|
|
201
|
-
} catch (_) {
|
|
202
|
-
return 0;
|
|
203
|
-
}
|
|
204
|
-
const now = Date.now();
|
|
205
|
-
for (const name of entries) {
|
|
206
|
-
if (!name.startsWith(LOCK_FILE_PREFIX)) continue;
|
|
207
|
-
const fp = path.join(dir, name);
|
|
208
|
-
const rec = readLockSafe(fp);
|
|
209
|
-
// Unparseable lock files are treated as stale — they're not protecting
|
|
210
|
-
// anything, and leaving them around defeats the cleaner.
|
|
211
|
-
if (!rec || now - rec.acquiredAt > limit) {
|
|
212
|
-
try {
|
|
213
|
-
fs.unlinkSync(fp);
|
|
214
|
-
removed++;
|
|
215
|
-
} catch (_) {
|
|
216
|
-
/* ignore */
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return removed;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
224
|
-
|
|
225
|
-
function lockPathFor(projectDir, sessionId) {
|
|
226
|
-
return path.join(projectDir, HANDOFF_DIR_REL, `${LOCK_FILE_PREFIX}${sessionId}`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function readLockSafe(fp) {
|
|
230
|
-
try {
|
|
231
|
-
const raw = fs.readFileSync(fp, "utf8");
|
|
232
|
-
const j = JSON.parse(raw);
|
|
233
|
-
if (
|
|
234
|
-
typeof j === "object" &&
|
|
235
|
-
j &&
|
|
236
|
-
typeof j.acquiredAt === "number" &&
|
|
237
|
-
typeof j.releaseBy === "number"
|
|
238
|
-
) {
|
|
239
|
-
return j;
|
|
240
|
-
}
|
|
241
|
-
return null;
|
|
242
|
-
} catch (_) {
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function ensureDir(d) {
|
|
248
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
249
|
-
}
|