@felixdotgo/aidlc-kit 0.0.1 → 0.1.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.
Files changed (92) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +5 -4
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  10. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +8 -1
  16. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +215 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +314 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +279 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +161 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +254 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +65 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +39 -4
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  43. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +309 -0
  44. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  45. package/dist/assets/.agents/aidlc/scripts/state.mjs +67 -11
  46. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  47. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  48. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  49. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  53. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  54. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  55. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  56. package/dist/services/aidlc-coordination/package.json +2 -1
  57. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  58. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  59. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  60. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  61. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +39 -4
  63. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  64. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  66. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  67. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  68. package/dist/src/adapters.js +25 -5
  69. package/dist/src/cli.js +66 -6
  70. package/dist/src/gate.js +3 -3
  71. package/dist/src/installer.d.ts +5 -1
  72. package/dist/src/installer.js +114 -30
  73. package/dist/src/managed-content.js +1 -1
  74. package/dist/src/model.d.ts +15 -0
  75. package/dist/src/model.js +1 -1
  76. package/dist/src/profiles.d.ts +3 -1
  77. package/dist/src/profiles.js +26 -3
  78. package/dist/src/state.d.ts +7 -0
  79. package/dist/src/state.js +54 -3
  80. package/dist/src/upgrade.d.ts +2 -2
  81. package/dist/src/upgrade.js +39 -4
  82. package/dist/src/workflow.d.ts +1 -0
  83. package/dist/src/workflow.js +2 -1
  84. package/docs/README.md +2 -2
  85. package/docs/architecture.md +6 -1
  86. package/docs/command-reference.md +63 -5
  87. package/docs/configuration.md +20 -1
  88. package/docs/coordination-service.md +54 -1
  89. package/docs/development.md +1 -1
  90. package/docs/operating-workflow.md +50 -14
  91. package/docs/testing-and-release.md +47 -4
  92. package/package.json +2 -2
@@ -0,0 +1,314 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readyTaskItems, formatGateView } from "./store.mjs";
3
+ import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
4
+
5
+ export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
6
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
7
+ const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
8
+ const integer = (value, name, max) => { if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(`${name} must be an integer from 1 to ${max}`); return value; };
9
+ const required = (object, method) => { if (typeof object?.[method] !== "function") throw new Error(`Fleet adapter requires ${method}()`); return object[method].bind(object); };
10
+ const assertId = (value, label) => { if (typeof value !== "string" || !ID.test(value)) throw new Error(`Invalid ${label}`); return value; };
11
+ const actionKind = (action) => { if (!["run_phase", "dispatch", "await_user", "reconcile", "blocked", "terminal", "complete"].includes(action?.classification)) throw new Error(`Invalid canonical nextAction: ${action?.classification}`); return action.classification; };
12
+
13
+ export function validateFleetLimits(input = {}) {
14
+ const value = { ...FLEET_LIMITS, ...input };
15
+ integer(value.maxTurns, "maxTurns", 1000);
16
+ integer(value.turnTimeoutMs, "turnTimeoutMs", 3_600_000);
17
+ integer(value.maxStallTurns, "maxStallTurns", 20);
18
+ integer(value.maxWorkers, "maxWorkers", 100);
19
+ integer(value.leaseMs, "leaseMs", 3_600_000);
20
+ integer(value.heartbeatMs, "heartbeatMs", 600_000);
21
+ if (value.heartbeatMs * 2 >= value.leaseMs) throw new Error("heartbeatMs must be less than half the leaseMs");
22
+ return value;
23
+ }
24
+
25
+ function validateSnapshot(snapshot, taskId) {
26
+ if (!snapshot || snapshot.task?.id !== taskId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) throw new Error(`Invalid remote task snapshot for ${taskId}`);
27
+ actionKind(snapshot.nextAction);
28
+ return snapshot;
29
+ }
30
+
31
+ function readyItems(task) {
32
+ const items = Array.isArray(task.tasks) ? task.tasks : [];
33
+ const ready = new Set(readyTaskItems(task).ready);
34
+ return items.filter((item) => item.status === "todo" && ready.has(item.id));
35
+ }
36
+ const overlaps = (left, right) => left === right || (left.endsWith("/") && right.startsWith(left)) || (right.endsWith("/") && left.startsWith(right));
37
+ const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => overlaps(a, b)));
38
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
39
+
40
+ function key(...parts) { return digest(parts); }
41
+
42
+ export async function reconcileFleetJobs({ repository, taskId, leadEpoch, snapshot, handoff, now = Date.now(), allowCodeOnlyRetry = false, quiesceMs = 0 }) {
43
+ const jobs = await required(repository, "listJobs")({ taskId });
44
+ if (!Array.isArray(jobs)) throw new Error("Invalid job list");
45
+ const pending = [];
46
+ for (const job of jobs) {
47
+ if (job.taskId !== taskId || !job.assignmentId || !job.itemId) throw new Error("Invalid assignment in job list");
48
+ const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
49
+ if (item?.status === "done") {
50
+ if (["reported", "uncertain"].includes(job.state)) {
51
+ const result = job.state === "reported" ? await required(handoff, "verify")({ job, snapshot }) : await required(handoff, "recover")({ job, snapshot });
52
+ if (!result?.valid || !result.alreadyIntegrated) pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: result?.reason ?? "Canonical item done but reported commit is not integrated" });
53
+ else {
54
+ if (job.state === "uncertain") await required(repository, "recoverJob")({ assignmentId: job.assignmentId, leadEpoch, commitSha: result.report.commitSha, changedPaths: result.report.changedPaths, idempotencyKey: key("recover", job.assignmentId, leadEpoch, result.commitSha) });
55
+ await required(repository, "reconcileJob")({ assignmentId: job.assignmentId, leadEpoch, decision: "integrated", idempotencyKey: key("integrated", job.assignmentId, leadEpoch, result.commitSha) });
56
+ }
57
+ }
58
+ continue;
59
+ }
60
+ if (job.state === "leased" && Date.parse(job.expiresAt) <= now) {
61
+ const reconcileJob = required(repository, "reconcileJob");
62
+ const updated = await reconcileJob({ assignmentId: job.assignmentId, leadEpoch, decision: "uncertain", reason: "worker lease expired", idempotencyKey: key("expire", job.assignmentId, leadEpoch, job.attempt, job.epoch) });
63
+ job.state = "uncertain";
64
+ job.uncertainAt = updated?.fleet?.jobs?.[job.assignmentId]?.uncertainAt ?? new Date(now).toISOString();
65
+ }
66
+ if (job.state === "uncertain") {
67
+ if (typeof handoff?.recover === "function") {
68
+ const recovered = await handoff.recover({ job, snapshot });
69
+ if (recovered?.recovered) {
70
+ await required(repository, "recoverJob")({ assignmentId: job.assignmentId, leadEpoch, commitSha: recovered.report.commitSha, changedPaths: recovered.report.changedPaths, idempotencyKey: key("recover", job.assignmentId, leadEpoch, recovered.report.commitSha) });
71
+ job.report = recovered.report;
72
+ job.state = "reported";
73
+ pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: "recovered pushed branch after lost report", artifact: { valid: true, ...recovered } });
74
+ } else if (allowCodeOnlyRetry && recovered?.retryable === true && Number.isFinite(Date.parse(job.uncertainAt))) {
75
+ const waitUntil = Date.parse(job.uncertainAt) + quiesceMs;
76
+ if (now < waitUntil) pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: "waiting for stale worker turn to quiesce", waitUntil });
77
+ else {
78
+ await required(repository, "reconcileJob")({ assignmentId: job.assignmentId, leadEpoch, decision: "ready", idempotencyKey: randomUUID() });
79
+ job.state = "ready";
80
+ }
81
+ } else pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: recovered?.reason ?? "uncertain assignment; inspect Git branch before retry" });
82
+ } else pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: "uncertain assignment; inspect Git branch before retry" });
83
+ } else if (job.state === "reported") {
84
+ if (typeof handoff?.verify !== "function") throw new Error("Reported job requires Git handoff verify adapter");
85
+ const result = await handoff.verify({ job, snapshot });
86
+ if (!result?.valid) pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: result?.reason ?? "Git artifact verification failed" });
87
+ else pending.push({ assignmentId: job.assignmentId, itemId: job.itemId, reason: "verified report awaits lead integration", artifact: result });
88
+ }
89
+ }
90
+ return { jobs, pending };
91
+ }
92
+
93
+ async function withHeartbeat(renew, intervalMs, work) {
94
+ const abort = new AbortController();
95
+ let failure;
96
+ let pending = Promise.resolve();
97
+ const timer = setInterval(() => {
98
+ pending = pending.then(() => renew()).catch((error) => { failure = error; abort.abort(error); });
99
+ }, intervalMs);
100
+ timer.unref?.();
101
+ try {
102
+ const result = await work(abort.signal);
103
+ await pending;
104
+ if (failure) throw new Error(`Fleet lease renewal failed: ${failure.message}`);
105
+ return result;
106
+ } finally { clearInterval(timer); await pending; }
107
+ }
108
+
109
+ /**
110
+ * One fenced coordinator invocation. The repository is the remote authority;
111
+ * no local lifecycle file or PID lease is consulted in fleet mode.
112
+ */
113
+ export async function runFleetCoordinator({ repository, taskId, ownerId = randomUUID(), workerIds = [], workerProviders, reservedJobs = [], allowCodeOnlyRetry = false, limits: inputLimits, pollMs = 5000, pickupTimeoutMs: inputPickupTimeout, packet, driver, gateView, handoff, completeItem, claimedLead, supervisorSignal, onEvent = () => {} }) {
114
+ assertId(taskId, "task ID"); assertId(ownerId, "owner ID");
115
+ if (!Array.isArray(workerIds) || new Set(workerIds).size !== workerIds.length) throw new Error("workerIds must be distinct IDs");
116
+ for (const workerId of workerIds) assertId(workerId, "worker ID");
117
+ if (workerProviders !== undefined && (!workerProviders || typeof workerProviders !== "object" || Array.isArray(workerProviders) || Object.keys(workerProviders).length !== workerIds.length || workerIds.some((workerId) => !isLoopProvider(workerProviders[workerId])) || Object.keys(workerProviders).some((workerId) => !workerIds.includes(workerId)))) throw new Error(`workerProviders must map each configured worker ID to ${LOOP_PROVIDER_LIST}`);
118
+ const limits = validateFleetLimits(inputLimits);
119
+ integer(pollMs, "pollMs", 300000);
120
+ const pickupTimeoutMs = integer(inputPickupTimeout ?? limits.leaseMs, "pickupTimeoutMs", 3_600_000);
121
+ const readTask = required(repository, "readTask");
122
+ const claimLead = required(repository, "claimLead");
123
+ const renewLead = required(repository, "renewLead");
124
+ const releaseLead = required(repository, "releaseLead");
125
+ const listJobs = required(repository, "listJobs");
126
+ const scheduleJob = required(repository, "scheduleJob");
127
+ const saveGateSnapshot = required(repository, "saveGateSnapshot");
128
+ let snapshot = validateSnapshot(await readTask({ taskId }), taskId);
129
+ const claim = claimedLead ?? await claimLead({ taskId, ownerId, leaseMs: limits.leaseMs, expectedRevision: snapshot.revision, idempotencyKey: randomUUID() });
130
+ if (!Number.isSafeInteger(claim?.epoch) || claim.epoch < 1) throw new Error("Lead claim lacks a fencing epoch");
131
+ const epoch = claim.epoch;
132
+ let turns = 0, stalls = 0, lastSignal = null;
133
+ const startedAt = Date.now();
134
+ const readySeen = new Map();
135
+ try {
136
+ return await withHeartbeat(() => renewLead({ taskId, ownerId, epoch, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (leaseSignal) => {
137
+ const signal = supervisorSignal ? AbortSignal.any([leaseSignal, supervisorSignal]) : leaseSignal;
138
+ for (;;) {
139
+ if (signal.aborted) throw new Error("Lead lease renewal failed");
140
+ if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
141
+ snapshot = validateSnapshot(await readTask({ taskId }), taskId);
142
+ const kind = actionKind(snapshot.nextAction);
143
+ if (kind === "await_user") {
144
+ const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
145
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
146
+ if (reconciled.pending.length) return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
147
+ const gate = snapshot.nextAction.gate;
148
+ const baseReview = await (gateView ?? ((value) => formatGateView(value.task, [], "markdown")))(snapshot);
149
+ if (typeof baseReview !== "string" || !baseReview.trim()) throw new Error("Empty gate review snapshot");
150
+ const integrationCommit = gate === "G2_codereview" ? await required(handoff, "currentCommit")() : undefined;
151
+ const review = integrationCommit ? `${baseReview.trimEnd()}\n\nIntegration commit: ${integrationCommit}\n` : baseReview;
152
+ const saved = await saveGateSnapshot({ taskId, leadEpoch: epoch, expectedRevision: snapshot.revision, gate, gateView: review, integrationCommit, idempotencyKey: key("gate", taskId, snapshot.revision, gate, integrationCommit ?? "") });
153
+ onEvent({ type: "gate", taskId, gate, snapshotId: saved.snapshotId });
154
+ return { status: "gate", taskId, gate, snapshotId: saved.snapshotId, revision: snapshot.revision, gateView: review };
155
+ }
156
+ if (["blocked", "terminal", "complete"].includes(kind)) return { status: kind, taskId, revision: snapshot.revision, nextAction: snapshot.nextAction };
157
+ if (turns >= limits.maxTurns) return { status: "paused", taskId, reason: "turn limit reached", revision: snapshot.revision };
158
+ const before = digest({ revision: snapshot.revision, action: snapshot.nextAction });
159
+ if (kind === "dispatch" || (kind === "run_phase" && snapshot.task.phase === "build")) {
160
+ const reconciled = await reconcileFleetJobs({ repository, taskId, leadEpoch: epoch, snapshot, handoff, allowCodeOnlyRetry, quiesceMs: limits.turnTimeoutMs + limits.heartbeatMs + 1000 });
161
+ if (reconciled.pending.length && reconciled.pending.every((entry) => entry.waitUntil)) { await wait(pollMs); continue; }
162
+ if (reconciled.pending.length) {
163
+ const verified = reconciled.pending.filter((entry) => entry.artifact?.valid);
164
+ if (verified.length && typeof handoff?.integrate === "function" && typeof completeItem === "function") {
165
+ for (const entry of verified) {
166
+ const job = reconciled.jobs.find((candidate) => candidate.assignmentId === entry.assignmentId);
167
+ const integrated = await handoff.integrate({ job, artifact: entry.artifact });
168
+ const fresh = validateSnapshot(await readTask({ taskId }), taskId);
169
+ await completeItem({ taskId, itemId: job.itemId, job, integrated, snapshot: fresh, leadEpoch: epoch });
170
+ const committed = validateSnapshot(await readTask({ taskId }), taskId);
171
+ if (committed.task.tasks?.find((item) => item.id === job.itemId)?.status !== "done") throw new Error(`Canonical item ${job.itemId} was not completed after integration`);
172
+ await required(repository, "reconcileJob")({ assignmentId: job.assignmentId, leadEpoch: epoch, decision: "integrated", idempotencyKey: key("integrated", job.assignmentId, epoch, integrated.integrationCommit) });
173
+ onEvent({ type: "job_integrated", taskId, itemId: job.itemId, assignmentId: job.assignmentId, commitSha: integrated.integrationCommit });
174
+ }
175
+ continue;
176
+ }
177
+ return { status: "reconcile", taskId, revision: snapshot.revision, pending: reconciled.pending };
178
+ }
179
+ const jobs = reconciled.jobs;
180
+ const active = jobs.filter((job) => ["ready", "leased", "reported", "uncertain"].includes(job.state));
181
+ const pathClaims = [...active, ...reservedJobs].filter((job) => ["ready", "leased", "reported", "uncertain"].includes(job.state));
182
+ const reserved = new Set(active.map((job) => job.itemId));
183
+ const load = new Map(workerIds.map((id) => [id, active.filter((job) => job.workerId === id).length]));
184
+ for (const item of readyItems(snapshot.task)) {
185
+ if (active.length >= limits.maxWorkers) break;
186
+ if (reserved.has(item.id)) continue;
187
+ if (!workerIds.length) throw new Error("Fleet dispatch requires at least one configured worker ID");
188
+ if (typeof handoff?.prepare !== "function") throw new Error("Fleet dispatch requires Git handoff prepare adapter");
189
+ const artifact = await handoff.prepare({ taskId, item, snapshot });
190
+ if (pathClaims.some((job) => pathsOverlap(artifact.ownedPaths, job.ownedPaths))) continue;
191
+ const workerId = [...load].sort((a, b) => a[1] - b[1])[0][0];
192
+ const agent = workerProviders ? workerProviders[workerId] : artifact.agent;
193
+ if (!isLoopProvider(agent)) throw new Error(`Invalid provider for worker ${workerId}`);
194
+ const job = await scheduleJob({ taskId, itemId: item.id, workerId, leadEpoch: epoch, expectedRevision: snapshot.revision, baseCommit: artifact.baseCommit, branch: artifact.branch, ownedPaths: artifact.ownedPaths, agent, idempotencyKey: randomUUID() });
195
+ active.push(job); reserved.add(item.id);
196
+ pathClaims.push({ ...job, ownedPaths: artifact.ownedPaths, state: "ready" });
197
+ load.set(workerId, load.get(workerId) + 1);
198
+ onEvent({ type: "job_scheduled", taskId, itemId: item.id, assignmentId: job.assignmentId });
199
+ }
200
+ if (active.length) {
201
+ for (const job of active) {
202
+ if (job.state !== "ready") { readySeen.delete(job.assignmentId); continue; }
203
+ if (!readySeen.has(job.assignmentId)) readySeen.set(job.assignmentId, Date.now());
204
+ if (Date.now() - readySeen.get(job.assignmentId) >= pickupTimeoutMs) {
205
+ await required(repository, "reconcileJob")({ assignmentId: job.assignmentId, leadEpoch: epoch, decision: "uncertain", idempotencyKey: randomUUID() });
206
+ return { status: "blocked", taskId, reason: `Worker did not claim assignment ${job.assignmentId} within ${pickupTimeoutMs} ms` };
207
+ }
208
+ }
209
+ onEvent({ type: "jobs_active", taskId, assignments: active.map((job) => job.assignmentId) });
210
+ await wait(pollMs);
211
+ continue;
212
+ }
213
+ }
214
+ if (kind === "reconcile") return { status: "reconcile", taskId, revision: snapshot.revision, nextAction: snapshot.nextAction };
215
+ if (typeof packet !== "function" || typeof driver !== "function") throw new Error("Fleet lead requires packet and driver adapters");
216
+ const prompt = await packet(snapshot, epoch);
217
+ if (typeof prompt !== "string" || !prompt) throw new Error("Empty fleet lead packet");
218
+ await driver({ taskId, snapshot, leadEpoch: epoch, prompt, timeoutMs: limits.turnTimeoutMs, signal });
219
+ turns++;
220
+ const after = validateSnapshot(await readTask({ taskId }), taskId);
221
+ const nextSignal = digest({ revision: after.revision, action: after.nextAction });
222
+ stalls = nextSignal === before || nextSignal === lastSignal ? stalls + 1 : 0;
223
+ lastSignal = nextSignal;
224
+ if (stalls >= limits.maxStallTurns) return { status: "paused", taskId, reason: `${stalls} lead turns without canonical progress`, revision: after.revision };
225
+ }
226
+ });
227
+ } finally {
228
+ await releaseLead({ taskId, ownerId, epoch }).catch((error) => onEvent({ type: "release_failed", taskId, message: error.message }));
229
+ }
230
+ }
231
+
232
+ /** A worker claims one attempt, persists its exact CLI session, and reports a Git artifact. */
233
+ export async function runFleetWorker({ repository, workerId = randomUUID(), workerAgent, preflight, limits: inputLimits, driver, packet, handoff, onEvent = () => {} }) {
234
+ assertId(workerId, "worker ID");
235
+ if (workerAgent !== undefined && !isLoopProvider(workerAgent)) throw new Error(`workerAgent must be ${LOOP_PROVIDER_LIST}`);
236
+ const limits = validateFleetLimits(inputLimits);
237
+ const claimJob = required(repository, "claimJob");
238
+ const renewJob = required(repository, "renewJob");
239
+ const checkpointJob = required(repository, "checkpointJob");
240
+ const reportJob = required(repository, "reportJob");
241
+ const readTask = required(repository, "readTask");
242
+ const job = await claimJob({ workerId, leaseMs: limits.leaseMs, idempotencyKey: key("worker-claim", workerId, randomUUID()) });
243
+ if (!job) return { status: "idle" };
244
+ if (!isLoopProvider(job.agent) || (workerAgent !== undefined && job.agent !== workerAgent)) throw new Error(`Assigned provider does not match worker ${workerId} capability`);
245
+ if (preflight !== undefined) {
246
+ if (typeof preflight !== "function") throw new Error("Worker preflight must be a function");
247
+ await preflight(job.agent);
248
+ }
249
+ assertId(job.taskId, "job task ID"); assertId(job.itemId, "job item ID");
250
+ if (!Number.isSafeInteger(job.epoch) || job.epoch < 1 || !Number.isSafeInteger(job.attempt) || job.attempt < 1 || !job.assignmentId) throw new Error("Job claim lacks a fenced attempt");
251
+ const identity = { assignmentId: job.assignmentId, attempt: job.attempt, epoch: job.epoch, workerId };
252
+ let sessionId = job.sessionId ?? null;
253
+ let turns = 0, stalls = 0;
254
+ const report = async (outcome) => reportJob({ ...identity, report: outcome, idempotencyKey: key("job-report", job.assignmentId, job.attempt, job.epoch, outcome.status, outcome.commitSha ?? "") });
255
+ return withHeartbeat(() => renewJob({ ...identity, leaseMs: limits.leaseMs }), limits.heartbeatMs, async (signal) => {
256
+ if (typeof driver !== "function" || typeof packet !== "function" || typeof handoff?.prepare !== "function" || typeof handoff?.observe !== "function" || typeof handoff?.finish !== "function") throw new Error("Worker requires driver, packet, and Git handoff adapters");
257
+ const workspace = await handoff.prepare(job);
258
+ if (!workspace?.root) throw new Error("Git handoff did not supply a worker root");
259
+ let lastObservation = null;
260
+ for (;;) {
261
+ if (signal.aborted) throw new Error("Job lease renewal failed");
262
+ const snapshot = validateSnapshot(await readTask({ taskId: job.taskId }), job.taskId);
263
+ const item = snapshot.task.tasks?.find((candidate) => candidate.id === job.itemId);
264
+ if (!item || item.status === "done") {
265
+ await report({ status: "superseded", reason: "Item absent or canonically done" });
266
+ return { status: "superseded", assignmentId: job.assignmentId };
267
+ }
268
+ const kind = actionKind(snapshot.nextAction);
269
+ if (kind === "await_user" || kind === "blocked" || kind === "terminal") {
270
+ await report({ status: "paused", reason: `Canonical action: ${kind}` });
271
+ return { status: "paused", assignmentId: job.assignmentId };
272
+ }
273
+ if (turns >= limits.maxTurns) {
274
+ await report({ status: "uncertain", reason: "turn limit reached", sessionId });
275
+ return { status: "uncertain", assignmentId: job.assignmentId };
276
+ }
277
+ const prompt = await packet({ job, snapshot, workspace, turns });
278
+ if (typeof prompt !== "string" || !prompt) throw new Error("Empty worker packet");
279
+ const result = await driver({ agent: job.agent, root: workspace.root, sessionId: sessionId ?? undefined, prompt: `${prompt}\n\nWorker scope: modify only assigned paths. Do not mutate AI-DLC lifecycle state or approve any gate. When the scoped implementation is ready for commit, end the final answer with AI-DLC-ITEM-COMPLETE on its own line.`, timeoutMs: limits.turnTimeoutMs, signal, onSessionId: async (id) => {
280
+ if (typeof id !== "string" || !id) throw new Error("Invalid worker session ID");
281
+ if (sessionId && sessionId !== id) throw new Error("Worker session changed during the same assignment");
282
+ sessionId = id;
283
+ }, onEvent });
284
+ if (result?.sessionId && sessionId && result.sessionId !== sessionId) throw new Error("Worker session changed during the same assignment");
285
+ if (result?.sessionId && !sessionId) {
286
+ sessionId = result.sessionId;
287
+ }
288
+ if (signal.aborted) throw new Error("Job lease renewal failed after worker turn");
289
+ await renewJob({ ...identity, leaseMs: limits.leaseMs });
290
+ if (signal.aborted) throw new Error("Job lease renewal failed after worker turn");
291
+ turns++;
292
+ if (sessionId) await checkpointJob({ ...identity, sessionId, checkpoint: { agent: job.agent, turns, completedAt: new Date().toISOString() } });
293
+ const observed = await handoff.observe({ job, workspace, result, turns });
294
+ if (observed?.done) {
295
+ await renewJob({ ...identity, leaseMs: limits.leaseMs });
296
+ if (signal.aborted) throw new Error("Job lease renewal failed before Git handoff");
297
+ const artifact = await handoff.finish({ job, workspace, result });
298
+ if (signal.aborted) throw new Error("Job lease renewal failed during Git handoff; reconcile remote state");
299
+ await renewJob({ ...identity, leaseMs: limits.leaseMs });
300
+ if (signal.aborted) throw new Error("Job lease renewal failed after Git handoff; reconcile remote state");
301
+ if (!artifact?.commitSha || !Array.isArray(artifact.changedPaths)) throw new Error("Invalid Git handoff artifact");
302
+ await report({ status: "reported", commitSha: artifact.commitSha, changedPaths: artifact.changedPaths, sessionId });
303
+ return { status: "reported", assignmentId: job.assignmentId, commitSha: artifact.commitSha };
304
+ }
305
+ const currentObservation = digest({ revision: snapshot.revision, artifact: observed?.fingerprint ?? null });
306
+ stalls = currentObservation === lastObservation ? stalls + 1 : 0;
307
+ lastObservation = currentObservation;
308
+ if (stalls >= limits.maxStallTurns) {
309
+ await report({ status: "uncertain", reason: `${stalls} worker turns without observable progress`, sessionId });
310
+ return { status: "uncertain", assignmentId: job.assignmentId };
311
+ }
312
+ }
313
+ });
314
+ }
@@ -0,0 +1,279 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { isLoopProvider } from "./loop-drivers.mjs";
6
+ import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
7
+
8
+ const SHA = /^[0-9a-f]{40,64}$/;
9
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$/;
11
+
12
+ function git(root, ...args) {
13
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim();
14
+ }
15
+ function validId(value, label) {
16
+ if (typeof value !== "string" || !ID.test(value)) throw new Error(`Invalid ${label}`);
17
+ return value;
18
+ }
19
+ function validSha(value, label) {
20
+ if (typeof value !== "string" || !SHA.test(value)) throw new Error(`Invalid ${label}`);
21
+ return value;
22
+ }
23
+ function validBranch(value) {
24
+ if (typeof value !== "string" || !BRANCH.test(value) || value.includes("..") || value.includes("//") || value.endsWith("/") || value.split("/").some((part) => part.startsWith("."))) throw new Error("Invalid Git branch");
25
+ return value;
26
+ }
27
+ function validPath(value, label = "path") {
28
+ if (typeof value !== "string" || !value || value.startsWith("/") || value.includes("\\") || value.includes("\0") || value.split("/").some((part, index, parts) => (!part && index !== parts.length - 1) || part === "." || part === "..") || value === ".git" || value.startsWith(".git/")) throw new Error(`Invalid ${label}: ${value}`);
29
+ return value;
30
+ }
31
+ function scopes(input) {
32
+ if (!Array.isArray(input) || input.length === 0) throw new Error("Assignment requires nonempty ownedPaths");
33
+ return [...new Set(input.map((path) => validPath(path, "owned path")))].sort();
34
+ }
35
+ function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope || (scope.endsWith("/") && path.startsWith(scope))); }
36
+ function changedPaths(root, base, head) {
37
+ const raw = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, head], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
38
+ return raw.toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")).sort();
39
+ }
40
+ function worktreePaths(root) {
41
+ const unstaged = execFileSync("git", ["diff", "--no-ext-diff", "--no-renames", "--name-only", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
42
+ const staged = execFileSync("git", ["diff", "--cached", "--no-ext-diff", "--no-renames", "--name-only", "-z", "HEAD"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
43
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
44
+ return [...new Set(Buffer.concat([unstaged, staged, untracked]).toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")))].sort();
45
+ }
46
+ function worktreeFingerprint(root, paths) {
47
+ // Hash the index as well as the worktree: a staged edit may differ from both
48
+ // HEAD and the current file, even when the latter has been restored to HEAD.
49
+ const index = execFileSync("git", ["ls-files", "--stage", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
50
+ const files = paths.map((path) => {
51
+ try {
52
+ const target = join(root, path);
53
+ const stat = lstatSync(target);
54
+ if (stat.isSymbolicLink()) return [path, "symlink", stat.mode, createHash("sha256").update(readlinkSync(target)).digest("hex")];
55
+ if (!stat.isFile()) throw new Error(`Unsupported changed path type: ${path}`);
56
+ return [path, "file", stat.mode, createHash("sha256").update(readFileSync(target)).digest("hex")];
57
+ } catch (error) {
58
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return [path, "deleted"];
59
+ throw error;
60
+ }
61
+ });
62
+ return createHash("sha256").update(git(root, "rev-parse", "HEAD")).update(index).update(JSON.stringify(files)).digest("hex");
63
+ }
64
+ function checkScope(paths, ownedPaths) {
65
+ const outside = paths.filter((path) => !inScope(path, ownedPaths));
66
+ if (outside.length) throw new Error(`Worker changed paths outside assignment: ${outside.join(", ")}`);
67
+ }
68
+ function remoteRef(remote, branch) { return `refs/remotes/${remote}/${branch}`; }
69
+ function fetchBranch(root, remote, branch) {
70
+ validBranch(branch);
71
+ git(root, "fetch", "--no-tags", remote, `refs/heads/${branch}:${remoteRef(remote, branch)}`);
72
+ return git(root, "rev-parse", remoteRef(remote, branch));
73
+ }
74
+ function ancestor(root, base, head) {
75
+ try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; }
76
+ }
77
+ function assertClean(root) {
78
+ if (git(root, "status", "--porcelain=v1", "--untracked-files=all")) throw new Error("Git worktree must be clean before handoff or integration");
79
+ }
80
+ function normaliseReport(job) {
81
+ const report = job.report ?? job;
82
+ const commitSha = validSha(report.commitSha, "reported commit SHA");
83
+ const changed = Array.isArray(report.changedPaths) ? [...new Set(report.changedPaths.map((path) => validPath(path, "reported path")))].sort() : null;
84
+ if (!changed) throw new Error("Report lacks changedPaths");
85
+ return { commitSha, changed };
86
+ }
87
+
88
+ /** The coordinator calls this from its protected integration checkout. */
89
+ export function createLeadGitHandoff({ root: inputRoot, integrationBranch, remote = "origin", agent = "codex" }) {
90
+ const root = resolve(inputRoot);
91
+ validBranch(integrationBranch); validId(remote, "remote");
92
+ if (!isLoopProvider(agent)) throw new Error("Invalid worker agent");
93
+ const current = () => { const branch = git(root, "symbolic-ref", "--short", "HEAD"); if (branch !== integrationBranch) throw new Error(`Expected integration branch ${integrationBranch}, found ${branch}`); return git(root, "rev-parse", "HEAD"); };
94
+ const syncCurrent = () => {
95
+ assertClean(root);
96
+ const localHead = current();
97
+ const remoteHead = fetchBranch(root, remote, integrationBranch);
98
+ if (localHead === remoteHead || ancestor(root, remoteHead, localHead)) return localHead;
99
+ if (!ancestor(root, localHead, remoteHead)) throw new Error("Integration branch diverged from remote; reconcile before dispatch");
100
+ git(root, "merge", "--ff-only", remoteHead);
101
+ return current();
102
+ };
103
+ return {
104
+ async currentCommit() {
105
+ const localHead = syncCurrent();
106
+ const remoteHead = fetchBranch(root, remote, integrationBranch);
107
+ if (localHead !== remoteHead) throw new Error("Integration commit is not published to the remote branch");
108
+ return localHead;
109
+ },
110
+ async prepare({ taskId, item }) {
111
+ validId(taskId, "task ID"); validId(item?.id, "item ID");
112
+ const ownedPaths = scopes(item.ownedPaths);
113
+ const baseCommit = syncCurrent();
114
+ const branch = validBranch(`aidlc/${taskId}/${item.id}/${randomUUID()}`);
115
+ return { baseCommit, branch, ownedPaths, agent: item.agent ?? agent };
116
+ },
117
+ async verify({ job }) {
118
+ try {
119
+ const branch = validBranch(job.branch);
120
+ const baseCommit = validSha(job.baseCommit, "base commit");
121
+ const ownedPaths = scopes(job.ownedPaths);
122
+ const { commitSha, changed } = normaliseReport(job);
123
+ const fetched = fetchBranch(root, remote, branch);
124
+ if (fetched !== commitSha) throw new Error("Reported commit does not match remote worker branch");
125
+ git(root, "cat-file", "-e", `${baseCommit}^{commit}`);
126
+ if (!ancestor(root, baseCommit, commitSha) || baseCommit === commitSha) throw new Error("Worker commit is not descended from assigned base");
127
+ const actualPaths = changedPaths(root, baseCommit, commitSha);
128
+ checkScope(actualPaths, ownedPaths);
129
+ if (JSON.stringify(actualPaths) !== JSON.stringify(changed)) throw new Error("Reported changed paths do not match Git commit");
130
+ const currentHead = syncCurrent();
131
+ if (!ancestor(root, baseCommit, currentHead)) throw new Error("Integration branch no longer contains assigned base");
132
+ return { valid: true, commitSha, baseCommit, branch, changedPaths: actualPaths, ownedPaths, alreadyIntegrated: ancestor(root, commitSha, currentHead) };
133
+ } catch (error) { return { valid: false, reason: error.message }; }
134
+ },
135
+ async recover({ job }) {
136
+ try {
137
+ const branch = validBranch(job.branch);
138
+ const baseCommit = validSha(job.baseCommit, "base commit");
139
+ const ownedPaths = scopes(job.ownedPaths);
140
+ try { git(root, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`); }
141
+ catch (error) {
142
+ if (error?.status === 2) return { recovered: false, retryable: true, reason: "Assigned remote branch has no pushed commit" };
143
+ throw error;
144
+ }
145
+ const commitSha = fetchBranch(root, remote, branch);
146
+ git(root, "cat-file", "-e", `${baseCommit}^{commit}`);
147
+ if (baseCommit === commitSha) return { recovered: false, retryable: true, reason: "Assigned remote branch remains at base commit" };
148
+ if (!ancestor(root, baseCommit, commitSha)) throw new Error("Worker branch is not descended from assigned base");
149
+ const changed = changedPaths(root, baseCommit, commitSha);
150
+ if (!changed.length) throw new Error("Worker branch has no changed paths");
151
+ checkScope(changed, ownedPaths);
152
+ const report = { commitSha, changedPaths: changed };
153
+ const verified = await this.verify({ job: { ...job, report } });
154
+ if (!verified.valid) throw new Error(verified.reason);
155
+ return { recovered: true, report, ...verified };
156
+ } catch (error) { return { recovered: false, reason: error.message }; }
157
+ },
158
+ async integrate({ job }) {
159
+ // A prior verification result is advisory: re-fetch the ref at integration
160
+ // time so a rewritten worker branch or stale report cannot be trusted.
161
+ const verified = await this.verify({ job });
162
+ if (!verified.valid) throw new Error(`Git artifact verification failed: ${verified.reason}`);
163
+ const before = syncCurrent();
164
+ if (ancestor(root, verified.commitSha, before)) {
165
+ try { git(root, "push", remote, `HEAD:refs/heads/${integrationBranch}`); }
166
+ catch (error) { throw new Error(`Integrated locally at ${before}, but remote push failed: ${error.message}`); }
167
+ return { integrated: false, alreadyIntegrated: true, commitSha: verified.commitSha, integrationCommit: before, changedPaths: verified.changedPaths };
168
+ }
169
+ try {
170
+ git(root, "-c", "user.name=AI-DLC lead", "-c", "user.email=aidlc-lead@localhost", "merge", "--no-ff", "--no-edit", verified.commitSha);
171
+ } catch (error) {
172
+ try { git(root, "merge", "--abort"); } catch { /* The original merge error is authoritative. */ }
173
+ throw new Error(`Git integration conflict or failure: ${error.message}`);
174
+ }
175
+ const integrationCommit = current();
176
+ try { git(root, "push", remote, `HEAD:refs/heads/${integrationBranch}`); }
177
+ catch (error) { throw new Error(`Integrated locally at ${integrationCommit}, but remote push failed: ${error.message}`); }
178
+ return { integrated: true, alreadyIntegrated: false, commitSha: verified.commitSha, integrationCommit, changedPaths: verified.changedPaths };
179
+ }
180
+ };
181
+ }
182
+
183
+ /** Workers receive only assignment metadata and never write the integration branch. */
184
+ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWorktreesRoot, remote = "origin" }) {
185
+ const root = resolve(inputRoot);
186
+ const worktreesRoot = resolve(inputWorktreesRoot);
187
+ validId(remote, "remote");
188
+ if (worktreesRoot === root || worktreesRoot.startsWith(`${root}/`)) throw new Error("Worker worktree directory must be outside the source checkout");
189
+ return {
190
+ async prepare(job) {
191
+ validId(job.assignmentId, "assignment ID");
192
+ const base = validSha(job.baseCommit, "base commit");
193
+ const branch = validBranch(job.branch);
194
+ scopes(job.ownedPaths);
195
+ mkdirSync(worktreesRoot, { recursive: true });
196
+ const workspace = join(worktreesRoot, job.assignmentId);
197
+ if (existsSync(workspace)) {
198
+ const actualBranch = git(workspace, "symbolic-ref", "--short", "HEAD");
199
+ if (actualBranch !== branch || !ancestor(workspace, base, "HEAD")) throw new Error("Existing worker worktree does not match assignment");
200
+ return { root: workspace, branch };
201
+ }
202
+ git(root, "fetch", "--no-tags", remote, base);
203
+ git(root, "worktree", "add", "-b", branch, workspace, base);
204
+ return { root: workspace, branch };
205
+ },
206
+ async observe({ job, workspace, result }) {
207
+ const changed = worktreePaths(workspace.root);
208
+ checkScope(changed, scopes(job.ownedPaths));
209
+ const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
210
+ return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
211
+ },
212
+ async finish({ job, workspace }) {
213
+ const branch = validBranch(job.branch);
214
+ if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
215
+ const ownedPaths = scopes(job.ownedPaths);
216
+ const dirty = worktreePaths(workspace.root);
217
+ checkScope(dirty, ownedPaths);
218
+ if (dirty.length) {
219
+ git(workspace.root, "add", "-A", "--", ...dirty);
220
+ git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${validId(job.taskId, "task ID")}/${validId(job.itemId, "item ID")} ${validId(job.assignmentId, "assignment ID")}`);
221
+ }
222
+ const commitSha = git(workspace.root, "rev-parse", "HEAD");
223
+ const base = validSha(job.baseCommit, "base commit");
224
+ if (commitSha === base) throw new Error("Worker produced no commit");
225
+ const changed = changedPaths(workspace.root, base, commitSha);
226
+ checkScope(changed, ownedPaths);
227
+ git(workspace.root, "push", remote, `HEAD:refs/heads/${branch}`);
228
+ return { commitSha, changedPaths: changed };
229
+ },
230
+ async discard({ workspace }) {
231
+ const path = resolve(workspace.root);
232
+ if (!path.startsWith(`${worktreesRoot}/`)) throw new Error("Invalid worker worktree path");
233
+ git(root, "worktree", "remove", "--force", path);
234
+ rmSync(path, { recursive: true, force: true });
235
+ }
236
+ };
237
+ }
238
+
239
+ /** Worker sees only a checked source snapshot. Git and credentials remain on host. */
240
+ export function createIsolatedWorkerGitHandoff(options) {
241
+ const host = createWorkerGitHandoff(options);
242
+ return {
243
+ async prepare(job) {
244
+ const checkout = await host.prepare(job);
245
+ if (worktreePaths(checkout.root).length || git(checkout.root, "rev-parse", "HEAD") !== job.baseCommit) throw new Error("Existing worker checkout requires reconciliation");
246
+ const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: checkout.root, encoding: "utf8", timeout: 30000 }).split("\0").filter((path) => path && path !== ".agents/data" && !path.startsWith(".agents/data/"));
247
+ const snapshot = mkdtempSync(join(resolve(options.worktreesRoot), "snapshot-"));
248
+ try { const baseline = createWorkerSnapshot(checkout.root, snapshot, tracked); return { root: snapshot, checkout, baseline }; }
249
+ catch (error) { rmSync(snapshot, {recursive:true,force:true}); throw error; }
250
+ },
251
+ async observe({job, workspace, result}) {
252
+ const changed = inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
253
+ const fingerprint = createHash("sha256").update(JSON.stringify(changed.map((path) => { const full=join(workspace.root,path); return existsSync(full) ? [path,createHash("sha256").update(readFileSync(full)).digest("hex"),lstatSync(full).mode] : [path,null]; }))).digest("hex");
254
+ const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
255
+ return {done, fingerprint};
256
+ },
257
+ async finish({job, workspace}) {
258
+ const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
259
+ if (!changed.length) throw new Error("Worker produced no changes");
260
+ if (worktreePaths(workspace.checkout.root).length || git(workspace.checkout.root,"rev-parse","HEAD") !== job.baseCommit) throw new Error("Host checkout changed during worker run");
261
+ for (const path of changed) {
262
+ const source=join(workspace.root,path), target=join(workspace.checkout.root,path);
263
+ let parent = workspace.checkout.root;
264
+ for (const segment of path.split("/").slice(0,-1)) {
265
+ parent = join(parent,segment);
266
+ try { const info=lstatSync(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe integration directory"); }
267
+ catch(error) { if (error.code !== "ENOENT") throw error; mkdirSync(parent); }
268
+ }
269
+ try { const info=lstatSync(target); if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new Error("Unsafe integration target"); }
270
+ catch(error) { if (error.code !== "ENOENT") throw error; }
271
+ if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
272
+ copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
273
+ }
274
+ const result=await host.finish({job,workspace:workspace.checkout});
275
+ rmSync(workspace.root,{recursive:true,force:true}); return result;
276
+ },
277
+ async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
278
+ };
279
+ }