@tekyzinc/gsd-t 3.18.17 → 3.19.0

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.
@@ -0,0 +1,211 @@
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
+ }
@@ -74,7 +74,8 @@ let _deprecatedWatchWarned = false;
74
74
  * sessionId?: string,
75
75
  * watch?: boolean,
76
76
  * spawnType?: 'primary' | 'validation',
77
- * env?: object
77
+ * env?: object,
78
+ * workerModel?: string
78
79
  * }} opts
79
80
  * @returns {{ id: string | null, pid: number | null, logPath: string | null, timestamp: string, mode: 'headless' | 'in-context' }}
80
81
  */
@@ -136,6 +137,24 @@ function autoSpawnHeadless(opts) {
136
137
  /* best-effort; fall through without banner port info */
137
138
  }
138
139
 
140
+ // M46 follow-up — Date + version banner. Printed before the transcript URL
141
+ // so multi-day-old read-backs are immediately dated. Best-effort.
142
+ try {
143
+ const { dateStamp } = require("../scripts/gsd-t-update-check.js");
144
+ const fsLocal = require("fs");
145
+ const osLocal = require("os");
146
+ const pathLocal = require("path");
147
+ let v = "unknown";
148
+ try {
149
+ v = fsLocal.readFileSync(
150
+ pathLocal.join(osLocal.homedir(), ".claude/.gsd-t-version"), "utf8"
151
+ ).trim();
152
+ } catch (_) { /* fall through with "unknown" */ }
153
+ process.stdout.write(`${dateStamp()}GSD-T v${v} — CURRENT\n`);
154
+ } catch (_) {
155
+ /* best-effort — never crash the spawn on banner failure */
156
+ }
157
+
139
158
  // M43 D6-T3 — Live transcript URL banner. Printed for every spawn so the
140
159
  // viewer at :PORT is "the" primary watching surface. Never throws.
141
160
  // Text is coordinated with D4 — exact line shape is part of
@@ -211,6 +230,14 @@ function autoSpawnHeadless(opts) {
211
230
  workerEnv[k] = String(v);
212
231
  }
213
232
  }
233
+ // Worker-model override (v3.18.18) — let `runDispatch` fan-outs default to
234
+ // Sonnet while the orchestrator stays on whatever the parent runs (often
235
+ // Opus). Moves mechanical fan-out work onto a separate rate-limit bucket,
236
+ // raising the provider concurrency ceiling from ~3 to ~6+ per the
237
+ // Max-subscription concurrency analysis (2026-04-23).
238
+ if (typeof opts.workerModel === "string" && opts.workerModel) {
239
+ workerEnv.ANTHROPIC_MODEL = opts.workerModel;
240
+ }
214
241
 
215
242
  const child = spawn("node", childArgs, {
216
243
  cwd: projectDir,
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * M46-D1 Iter-Parallel Proof Harness (T10)
6
+ *
7
+ * Measures the parallelism speedup of `_runIterParallel` vs. a serial
8
+ * baseline using a synthetic 10-iter workload where each iter sleeps
9
+ * 200ms and returns a successful IterResult.
10
+ *
11
+ * Method:
12
+ * 1. Serial baseline (batchSize=1): run 10 iters one at a time via
13
+ * `_runIterParallel(..., batchSize=1)` in a loop. Expect ~2000ms.
14
+ * 2. Parallel (batchSize=4): run 10 iters in batches of 4+4+2 via the
15
+ * same helper. Expect ~600ms (3 batches × 200ms).
16
+ * 3. Compute speedup = T_serial / T_par,
17
+ * parallelism_factor = (10 × 200) / T_par.
18
+ * 4. Pass iff T_par/T_serial ≤ 0.35 AND speedup ≥ 3.0.
19
+ *
20
+ * Output: .gsd-t/metrics/m46-iter-proof.json + human summary on stdout.
21
+ * Exit: 0 on pass, 1 on fail.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const ITER_COUNT = 10;
28
+ const ITER_SLEEP_MS = 200;
29
+ const BATCH_SERIAL = 1;
30
+ const BATCH_PARALLEL = 4;
31
+ const THRESHOLD_RATIO = 0.35;
32
+ const THRESHOLD_SPEEDUP = 3.0;
33
+
34
+ const { _runIterParallel } = require(path.join(__dirname, 'gsd-t-unattended.cjs')).__test__;
35
+
36
+ // ── synthetic iterFn ───────────────────────────────────────────────────────
37
+
38
+ let iterSeq = 0;
39
+ function makeIterFn(sleepMs) {
40
+ return async function fakeIter(_state, _opts) {
41
+ const id = iterSeq++;
42
+ await new Promise((r) => setTimeout(r, sleepMs));
43
+ return {
44
+ status: 'ok',
45
+ tasksDone: ['t' + id],
46
+ verifyNeeded: false,
47
+ artifacts: [],
48
+ };
49
+ };
50
+ }
51
+
52
+ // ── runners ────────────────────────────────────────────────────────────────
53
+
54
+ async function runSerial(total, sleepMs) {
55
+ const iterFn = makeIterFn(sleepMs);
56
+ const state = {};
57
+ const opts = {};
58
+ const t0 = process.hrtime.bigint();
59
+ const results = [];
60
+ for (let i = 0; i < total; i++) {
61
+ const batch = await _runIterParallel(state, opts, iterFn, BATCH_SERIAL);
62
+ results.push(...batch);
63
+ }
64
+ const t1 = process.hrtime.bigint();
65
+ return {
66
+ wallClockMs: Number(t1 - t0) / 1e6,
67
+ results,
68
+ };
69
+ }
70
+
71
+ async function runParallel(total, batchSize, sleepMs) {
72
+ const iterFn = makeIterFn(sleepMs);
73
+ const state = {};
74
+ const opts = {};
75
+ const t0 = process.hrtime.bigint();
76
+ const results = [];
77
+ let remaining = total;
78
+ while (remaining > 0) {
79
+ const n = Math.min(batchSize, remaining);
80
+ const batch = await _runIterParallel(state, opts, iterFn, n);
81
+ results.push(...batch);
82
+ remaining -= n;
83
+ }
84
+ const t1 = process.hrtime.bigint();
85
+ return {
86
+ wallClockMs: Number(t1 - t0) / 1e6,
87
+ results,
88
+ };
89
+ }
90
+
91
+ // ── main ───────────────────────────────────────────────────────────────────
92
+
93
+ async function main() {
94
+ process.stdout.write(
95
+ `M46-D1 Iter-Parallel Proof (N=${ITER_COUNT} iters, sleep=${ITER_SLEEP_MS}ms each)\n`,
96
+ );
97
+ process.stdout.write('─'.repeat(60) + '\n');
98
+
99
+ iterSeq = 0;
100
+ const serial = await runSerial(ITER_COUNT, ITER_SLEEP_MS);
101
+ process.stdout.write(`Serial baseline (batchSize=${BATCH_SERIAL}, ${ITER_COUNT} iters sequentially)\n`);
102
+ process.stdout.write(` T_serial (wall-clock): ${serial.wallClockMs.toFixed(1)} ms\n`);
103
+ process.stdout.write(` results: ${serial.results.length} (ok=${serial.results.filter((r) => r.status === 'ok').length})\n\n`);
104
+
105
+ iterSeq = 0;
106
+ const parallel = await runParallel(ITER_COUNT, BATCH_PARALLEL, ITER_SLEEP_MS);
107
+ process.stdout.write(`Parallel (batchSize=${BATCH_PARALLEL}, batches of 4+4+2)\n`);
108
+ process.stdout.write(` T_par (wall-clock): ${parallel.wallClockMs.toFixed(1)} ms\n`);
109
+ process.stdout.write(` results: ${parallel.results.length} (ok=${parallel.results.filter((r) => r.status === 'ok').length})\n\n`);
110
+
111
+ const ratio = parallel.wallClockMs / serial.wallClockMs;
112
+ const speedup = serial.wallClockMs / parallel.wallClockMs;
113
+ const parallelismFactor = (ITER_COUNT * ITER_SLEEP_MS) / parallel.wallClockMs;
114
+ const passed = ratio <= THRESHOLD_RATIO && speedup >= THRESHOLD_SPEEDUP;
115
+
116
+ process.stdout.write('─'.repeat(60) + '\n');
117
+ process.stdout.write('Result\n');
118
+ process.stdout.write(` T_par / T_serial = ${ratio.toFixed(3)} (threshold ≤ ${THRESHOLD_RATIO})\n`);
119
+ process.stdout.write(` speedup = ${speedup.toFixed(2)}× (threshold ≥ ${THRESHOLD_SPEEDUP})\n`);
120
+ process.stdout.write(` parallelism_factor = ${parallelismFactor.toFixed(2)} (ideal ≈ ${BATCH_PARALLEL})\n`);
121
+ process.stdout.write(` verdict = ${passed ? 'PASS ✓' : 'FAIL ✗'}\n`);
122
+
123
+ const report = {
124
+ timestamp: new Date().toISOString(),
125
+ iter_count: ITER_COUNT,
126
+ iter_sleep_ms: ITER_SLEEP_MS,
127
+ batch_size_serial: BATCH_SERIAL,
128
+ batch_size_parallel: BATCH_PARALLEL,
129
+ T_serial_ms: serial.wallClockMs,
130
+ T_par_ms: parallel.wallClockMs,
131
+ speedup,
132
+ parallelism_factor: parallelismFactor,
133
+ threshold_T_par_over_T_serial: THRESHOLD_RATIO,
134
+ threshold_speedup: THRESHOLD_SPEEDUP,
135
+ passed,
136
+ };
137
+ const reportDir = path.join(process.cwd(), '.gsd-t', 'metrics');
138
+ fs.mkdirSync(reportDir, { recursive: true });
139
+ const reportPath = path.join(reportDir, 'm46-iter-proof.json');
140
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
141
+ process.stdout.write(`\nReport written: ${reportPath}\n`);
142
+
143
+ process.exit(passed ? 0 : 1);
144
+ }
145
+
146
+ main().catch((e) => {
147
+ process.stderr.write(`ERROR: ${(e && e.stack) || e}\n`);
148
+ process.exit(2);
149
+ });
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * M46-D2 Worker Sub-Dispatch Proof Harness (T7)
6
+ *
7
+ * Measures the parallelism speedup of `dispatchWorkerTasks` vs. a serial
8
+ * baseline using a synthetic 6-task file-disjoint workload.
9
+ *
10
+ * Method:
11
+ * 1. Serial baseline: run 6 `sleep 2 && echo done > …` tasks one by one
12
+ * via `child_process.execSync`. Record `T_serial` (wall-clock ms).
13
+ * 2. Parallel via `dispatchWorkerTasks`: stub the dispatcher's
14
+ * `runDispatch` boundary (via a mocked `gsd-t-parallel.cjs` in
15
+ * require.cache) to execute tasks concurrently with
16
+ * `child_process.exec` + `Promise.all`. This measures the dispatch
17
+ * SCHEDULER's fan-out behaviour, not real headless claude-p spawns.
18
+ * 3. Compute speedup and parallelism_factor. Pass iff speedup ≥ 2.5.
19
+ *
20
+ * Output: .gsd-t/metrics/m46-worker-proof.json + human summary on stdout.
21
+ * Exit: 0 on pass, 1 on fail.
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const { exec, execSync } = require('child_process');
28
+
29
+ const THRESHOLD = 2.5;
30
+ const TASK_COUNT = 6;
31
+ const PID = process.pid;
32
+ const TMP_DIR = path.join(os.tmpdir(), `m46-proof-${PID}`);
33
+
34
+ function ensureTmpDir() {
35
+ fs.mkdirSync(TMP_DIR, { recursive: true });
36
+ }
37
+
38
+ function cleanupTmpDir() {
39
+ try { fs.rmSync(TMP_DIR, { recursive: true, force: true }); } catch (_) { /* ignore */ }
40
+ }
41
+
42
+ function buildTasks() {
43
+ const tasks = [];
44
+ for (let i = 0; i < TASK_COUNT; i++) {
45
+ const outPath = path.join(TMP_DIR, `task-${i}.out`);
46
+ tasks.push({
47
+ taskId: `T-${i}`,
48
+ files: [outPath],
49
+ command: `sleep 2 && echo done > ${outPath}`,
50
+ });
51
+ }
52
+ return tasks;
53
+ }
54
+
55
+ // ── serial baseline ────────────────────────────────────────────────────────
56
+
57
+ function runSerial(tasks) {
58
+ const perTaskMs = [];
59
+ const t0 = process.hrtime.bigint();
60
+ for (const task of tasks) {
61
+ const ts = process.hrtime.bigint();
62
+ execSync(task.command, { stdio: 'ignore' });
63
+ const te = process.hrtime.bigint();
64
+ perTaskMs.push(Number(te - ts) / 1e6);
65
+ }
66
+ const t1 = process.hrtime.bigint();
67
+ return {
68
+ wallClockMs: Number(t1 - t0) / 1e6,
69
+ perTaskMs,
70
+ meanTaskMs: perTaskMs.reduce((a, b) => a + b, 0) / perTaskMs.length,
71
+ };
72
+ }
73
+
74
+ // ── parallel via stubbed runDispatch ──────────────────────────────────────
75
+
76
+ function installParallelStub() {
77
+ // Pre-populate require.cache for bin/gsd-t-parallel.cjs with a stub whose
78
+ // `runDispatch` executes the task `command` field concurrently via exec +
79
+ // Promise.all. This isolates measurement to the dispatcher scheduler and
80
+ // avoids invoking real claude-p children.
81
+ const parallelPath = require.resolve(path.join(__dirname, 'gsd-t-parallel.cjs'));
82
+ const stubModule = {
83
+ exports: {
84
+ runDispatch: async ({ tasks }) => {
85
+ const workerResults = await Promise.all(tasks.map((task) => new Promise((resolve) => {
86
+ const started = Date.now();
87
+ exec(task.command, (err) => {
88
+ resolve({
89
+ taskId: task.taskId,
90
+ exitCode: err ? (err.code || 1) : 0,
91
+ durationMs: Date.now() - started,
92
+ });
93
+ });
94
+ })));
95
+ return {
96
+ decision: 'fan-out',
97
+ fanOutCount: tasks.length,
98
+ workerResults,
99
+ };
100
+ },
101
+ },
102
+ loaded: true,
103
+ id: parallelPath,
104
+ filename: parallelPath,
105
+ paths: [],
106
+ children: [],
107
+ };
108
+ require.cache[parallelPath] = stubModule;
109
+ }
110
+
111
+ async function runParallel(tasks) {
112
+ installParallelStub();
113
+ // Fresh require after stub install, in case worker-dispatch was pre-loaded.
114
+ const dispatchPath = require.resolve(path.join(__dirname, 'gsd-t-worker-dispatch.cjs'));
115
+ delete require.cache[dispatchPath];
116
+ const { dispatchWorkerTasks } = require(dispatchPath);
117
+
118
+ const t0 = process.hrtime.bigint();
119
+ const result = await dispatchWorkerTasks({
120
+ projectDir: TMP_DIR,
121
+ parentSessionId: `m46-proof-${PID}`,
122
+ tasks,
123
+ maxParallel: TASK_COUNT,
124
+ });
125
+ const t1 = process.hrtime.bigint();
126
+
127
+ return {
128
+ wallClockMs: Number(t1 - t0) / 1e6,
129
+ parallel: result.parallel,
130
+ reason: result.reason,
131
+ taskResults: result.taskResults,
132
+ };
133
+ }
134
+
135
+ // ── main ────────────────────────────────────────────────────────────────────
136
+
137
+ async function main() {
138
+ ensureTmpDir();
139
+ process.stdout.write(`M46-D2 Worker Sub-Dispatch Proof (N=${TASK_COUNT} tasks, tmp=${TMP_DIR})\n`);
140
+ process.stdout.write('─'.repeat(60) + '\n');
141
+
142
+ const tasks = buildTasks();
143
+
144
+ const serial = runSerial(tasks);
145
+ process.stdout.write('Serial baseline (execSync, tasks run back-to-back)\n');
146
+ process.stdout.write(` T_serial (wall-clock): ${serial.wallClockMs.toFixed(1)} ms\n`);
147
+ process.stdout.write(` per-task durations: [${serial.perTaskMs.map((x) => x.toFixed(0)).join(', ')}] ms\n`);
148
+ process.stdout.write(` mean(task duration): ${serial.meanTaskMs.toFixed(1)} ms\n\n`);
149
+
150
+ // Reset task output files before parallel run so exec doesn't race on
151
+ // leftovers (each task writes its own file so this is just hygiene).
152
+ for (let i = 0; i < TASK_COUNT; i++) {
153
+ const p = path.join(TMP_DIR, `task-${i}.out`);
154
+ try { fs.unlinkSync(p); } catch (_) { /* ignore */ }
155
+ }
156
+
157
+ const parallel = await runParallel(tasks);
158
+ process.stdout.write(`Parallel via dispatchWorkerTasks (runDispatch stub, concurrent exec)\n`);
159
+ process.stdout.write(` T_par (wall-clock): ${parallel.wallClockMs.toFixed(1)} ms\n`);
160
+ process.stdout.write(` parallel dispatched: ${parallel.parallel} (reason=${parallel.reason})\n`);
161
+ process.stdout.write(` per-task results: ${parallel.taskResults.length} tasks\n\n`);
162
+
163
+ const speedup = serial.wallClockMs / parallel.wallClockMs;
164
+ const parallelismFactor = (TASK_COUNT * serial.meanTaskMs) / parallel.wallClockMs;
165
+ const passed = speedup >= THRESHOLD;
166
+
167
+ process.stdout.write('─'.repeat(60) + '\n');
168
+ process.stdout.write('Result\n');
169
+ process.stdout.write(` speedup = ${speedup.toFixed(2)}× (threshold ≥ ${THRESHOLD})\n`);
170
+ process.stdout.write(` parallelism_factor = ${parallelismFactor.toFixed(2)} (ideal = ${TASK_COUNT})\n`);
171
+ process.stdout.write(` verdict = ${passed ? 'PASS ✓' : 'FAIL ✗'}\n`);
172
+
173
+ const report = {
174
+ timestamp: new Date().toISOString(),
175
+ task_count: TASK_COUNT,
176
+ T_serial_ms: serial.wallClockMs,
177
+ T_par_ms: parallel.wallClockMs,
178
+ speedup,
179
+ parallelism_factor: parallelismFactor,
180
+ threshold: THRESHOLD,
181
+ passed,
182
+ };
183
+ const reportDir = path.join(process.cwd(), '.gsd-t', 'metrics');
184
+ fs.mkdirSync(reportDir, { recursive: true });
185
+ const reportPath = path.join(reportDir, 'm46-worker-proof.json');
186
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
187
+ process.stdout.write(`\nReport written: ${reportPath}\n`);
188
+
189
+ cleanupTmpDir();
190
+ process.exit(passed ? 0 : 1);
191
+ }
192
+
193
+ process.on('exit', cleanupTmpDir);
194
+ process.on('SIGINT', () => { cleanupTmpDir(); process.exit(130); });
195
+ process.on('SIGTERM', () => { cleanupTmpDir(); process.exit(143); });
196
+
197
+ main().catch((e) => {
198
+ process.stderr.write(`ERROR: ${(e && e.stack) || e}\n`);
199
+ cleanupTmpDir();
200
+ process.exit(2);
201
+ });
@@ -41,7 +41,7 @@ const SPAWN_PLAN_SCHEMA_VERSION = 1;
41
41
  *
42
42
  * @param {object} opts
43
43
  * @param {string} opts.spawnId filesystem-safe id
44
- * @param {'unattended-worker'|'headless-detached'|'in-session-subagent'} opts.kind
44
+ * @param {'unattended-worker'|'headless-detached'|'in-session-subagent'|'unattended-worker-sub'} opts.kind
45
45
  * @param {string} [opts.milestone] e.g. 'M44'
46
46
  * @param {string} [opts.wave] e.g. 'wave-3'
47
47
  * @param {string[]} [opts.domains] domain names involved
@@ -12,6 +12,38 @@ node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-
12
12
 
13
13
  **Worker bypass**: If the environment variable `GSD_T_UNATTENDED_WORKER=1` is set, this resume is being invoked by the unattended supervisor as a worker iteration. **SKIP this entire Step 0** — do NOT check for supervisor.pid, do NOT auto-reattach, do NOT schedule a watch tick. Fall through directly to Step 0.1. The worker's job is to do actual work, not watch itself.
14
14
 
15
+ ### Worker Sub-Dispatch (M46-D2)
16
+
17
+ When the worker bypass above applies (`GSD_T_UNATTENDED_WORKER=1` is set), the worker performs an **additional deterministic check** BEFORE falling through to Step 0.1. This lets a worker with multiple file-disjoint tasks fan out its own work as concurrent headless sub-workers, rather than running its task set serially.
18
+
19
+ Decision rule (all conditions must hold):
20
+ - `GSD_T_WORKER_TASK_IDS` is present in the environment (the supervisor declared the task set for this worker).
21
+ - The worker has **more than one task** to run (`tasks.length > 1`).
22
+ - The supervisor has exported the worker's task list to a JSON file on disk (the tasks file path is provided via env or the worker's continue-here block).
23
+ - The tasks are pairwise **file-disjoint** per `.gsd-t/contracts/file-disjointness-rules.md` (the dispatcher re-verifies this internally; the worker does not need to pre-check).
24
+
25
+ If all conditions hold, the worker invokes:
26
+
27
+ ```bash
28
+ node bin/gsd-t-worker-dispatch.cjs \
29
+ --parent-session "$GSD_T_PARENT_AGENT_ID" \
30
+ --tasks <path-to-tasks-json>
31
+ ```
32
+
33
+ Interpret the exit code as follows:
34
+ - **Exit 0** → every sub-worker succeeded. Aggregate the stdout JSON result (which contains `{parallel, taskResults, wallClockMs, reason}`) and report completion of the worker iteration with all taskIds marked done. Do NOT fall through to the serial pre-M46 path.
35
+ - **Exit 1** → at least one sub-worker failed. Report mixed status for the worker iteration, listing the failed `taskId`s from the aggregated `taskResults` (any entry with a non-zero, non-null `exitCode`). The sibling sub-workers' successful results still count — do NOT re-run them. Do NOT fall through to the serial path.
36
+ - **Exit 2** → precondition failure (missing `--parent-session`, unreadable or malformed tasks JSON). Log the stderr reason and **fall through to the current (pre-M46) worker behavior** — the serial path that existed before D2.
37
+
38
+ Skip sub-dispatch and fall through to the current behavior when:
39
+ - `tasks.length <= 1` (single-task or empty workloads have nothing to parallelize — the dispatcher itself returns early with `reason: 'single-task'` / `'no-tasks'`, but the worker can short-circuit without spawning the adapter at all).
40
+ - Tasks are not file-disjoint per the disjointness contract (the dispatcher will return `parallel: false, reason: 'file-overlap'`; the worker treats this identically to the serial fallback).
41
+ - The tasks file was not written by the supervisor (no sub-dispatch surface available this iteration).
42
+
43
+ **Rationale** — Per `.gsd-t/contracts/headless-default-contract.md` §Worker Sub-Dispatch (v2.1.0), this hand-off is the third parallelism layer (iter-parallel → supervisor fan-out → worker sub-dispatch). It is a deterministic, file-disjointness-gated consumer of the M44-verified `runDispatch` instrument — not a modification of it. The in-session dispatch path is byte-identical; this sub-section only adds a new invocation site on the unattended worker leg.
44
+
45
+ ---
46
+
15
47
  Check whether an unattended supervisor is actively running for this project:
16
48
 
17
49
  1. Check if `.gsd-t/.unattended/supervisor.pid` exists.
@@ -19,6 +19,16 @@ Wait for the subagent to complete. Relay its output to the user. **Do not read f
19
19
  **If you are the spawned subagent** (your prompt says "running gsd-t-status"):
20
20
  Continue below.
21
21
 
22
+ ## Step 0.0: Date + Version Banner (MANDATORY)
23
+
24
+ Before anything else, print the current date and GSD-T version so a multi-day-old session is immediately dated when the user reads the output:
25
+
26
+ ```bash
27
+ node -e "const{dateStamp}=require('./scripts/gsd-t-update-check.js');const fs=require('fs'),os=require('os'),path=require('path');const v=(()=>{try{return fs.readFileSync(path.join(os.homedir(),'.claude/.gsd-t-version'),'utf8').trim()}catch{return 'unknown'}})();process.stdout.write(dateStamp()+'GSD-T v'+v+' — CURRENT\n')" 2>/dev/null || true
28
+ ```
29
+
30
+ Format: `Tue: Mar 26, 2026, GSD-T v3.19.00 — CURRENT`. Currency claim is best-effort — the canonical authority is the `~/.claude/.gsd-t-update-check` cache consulted by the SessionStart hook; status mode trusts the installed version label.
31
+
22
32
  ## Step 0: Headless Read-Back Banner (MANDATORY)
23
33
 
24
34
  Before reading any files, surface any completed headless sessions the user hasn't seen yet. Run this once at the start of every status invocation: