@cassiomc1/forgeloop 1.2.4 → 1.5.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/.github/copilot-instructions.md +1 -0
  2. package/AGENTS.md +1 -0
  3. package/AGENT_COMPATIBILITY.md +4 -0
  4. package/CLAUDE.md +1 -0
  5. package/DOCS_INDEX.md +14 -0
  6. package/EXECUTION_STATE.md +48 -0
  7. package/LOOP_ENGINEERING.md +55 -6
  8. package/LOOP_SYSTEM_DESIGN.md +32 -1
  9. package/PROTOCOL_INTEGRATION.md +71 -0
  10. package/README.md +86 -0
  11. package/TERMINOLOGY.md +15 -0
  12. package/THIRD_PARTY_NOTICES.md +15 -0
  13. package/THREAT_MODEL.md +22 -1
  14. package/docs/ARTIFACT_REFERENCE.md +54 -0
  15. package/docs/CLI_REFERENCE.md +177 -5
  16. package/docs/CROSS_HARNESS_CONTINUITY.md +34 -0
  17. package/docs/DOCUMENTATION_GUIDE.md +31 -0
  18. package/docs/GETTING_STARTED.md +10 -0
  19. package/docs/MCP.md +126 -0
  20. package/docs/RECIPES.md +87 -1
  21. package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
  22. package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
  23. package/docs/TROUBLESHOOTING.md +243 -40
  24. package/docs/UNIVERSAL_INTEGRATION.md +48 -0
  25. package/package.json +17 -3
  26. package/schemas/execution.schema.json +11 -1
  27. package/schemas/task-recovery.schema.json +61 -0
  28. package/schemas/work-state.schema.json +1 -0
  29. package/src/cli.js +182 -337
  30. package/src/commands/audit.js +5 -0
  31. package/src/commands/doctor.js +22 -0
  32. package/src/commands/inspect.js +6 -0
  33. package/src/commands/migrate-protocol.js +18 -0
  34. package/src/commands/progress.js +6 -2
  35. package/src/commands/protocol-info.js +16 -0
  36. package/src/commands/run-check.js +2 -0
  37. package/src/commands/status.js +17 -0
  38. package/src/commands/task-create.js +42 -3
  39. package/src/commands/task-list.js +14 -1
  40. package/src/commands/task-lock-status.js +29 -0
  41. package/src/commands/task-recover.js +202 -0
  42. package/src/commands/task-repair-legacy-recovery.js +417 -0
  43. package/src/commands/task-resume.js +172 -0
  44. package/src/commands/task-scope.js +23 -4
  45. package/src/commands/task-show.js +21 -7
  46. package/src/commands/task-unlock.js +8 -6
  47. package/src/commands/validate-protocol.js +19 -2
  48. package/src/core/artifact-registry.js +12 -0
  49. package/src/core/artifacts.js +17 -4
  50. package/src/core/audit.js +20 -4
  51. package/src/core/bundles.js +15 -0
  52. package/src/core/cli-command-definitions.js +94 -4
  53. package/src/core/command-executors.js +387 -0
  54. package/src/core/command-input.js +107 -0
  55. package/src/core/command-runtime.js +106 -0
  56. package/src/core/completion-artifacts.js +17 -6
  57. package/src/core/completion-ownership.js +88 -0
  58. package/src/core/completion.js +15 -3
  59. package/src/core/diagnosis.js +15 -11
  60. package/src/core/error-codes.js +136 -1
  61. package/src/core/events.js +239 -9
  62. package/src/core/execution.js +73 -9
  63. package/src/core/filesystem.js +75 -8
  64. package/src/core/inspect.js +27 -0
  65. package/src/core/integration-invocation-policy.js +170 -0
  66. package/src/core/integration-limits.js +20 -0
  67. package/src/core/integration-resources.js +127 -0
  68. package/src/core/next-action-model.js +60 -0
  69. package/src/core/next-action.js +31 -0
  70. package/src/core/phase.js +10 -3
  71. package/src/core/project-root.js +21 -0
  72. package/src/core/protocol-info.js +54 -0
  73. package/src/core/protocol-migration.js +59 -0
  74. package/src/core/reconcile-closure.js +54 -14
  75. package/src/core/recovery-history.js +116 -0
  76. package/src/core/resumability.js +8 -6
  77. package/src/core/schema-validation.js +1 -0
  78. package/src/core/task-claim-state.js +272 -0
  79. package/src/core/task-command.js +8 -4
  80. package/src/core/task-conflict-inspection.js +321 -0
  81. package/src/core/task-context.js +32 -29
  82. package/src/core/task-discovery.js +14 -1
  83. package/src/core/task-lock.js +248 -18
  84. package/src/core/task-migration.js +24 -1
  85. package/src/core/task-paths.js +6 -3
  86. package/src/core/task-recovery-migration.js +192 -0
  87. package/src/core/task-recovery.js +205 -0
  88. package/src/core/task-scope.js +33 -1
  89. package/src/core/templates.js +1 -0
  90. package/src/core/transaction.js +285 -0
  91. package/src/core/work-state.js +70 -6
  92. package/src/integration.js +47 -0
@@ -1,5 +1,5 @@
1
1
 
2
- import { appendFile, mkdir, readFile } from "node:fs/promises";
2
+ import { appendFile, mkdir, open, readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
@@ -11,9 +11,17 @@ import { PROTOCOL_VERSION } from "./protocol.js";
11
11
  import { isRecoverableCompletionEvidenceCode } from "./completion-recovery.js";
12
12
 
13
13
  import { taskArtifactPath } from "./task-paths.js";
14
+ import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
14
15
 
15
16
  import { assertDiagnosisDetails } from "./diagnosis-model.js";
16
17
  import { assertDecisionCriterionDetails } from "./settlement-model.js";
18
+ import {
19
+ LEGACY_RECOVERY_MIGRATION_EVENT,
20
+ assertLegacyMigrationDetails,
21
+ isLegacyRecoveryDetailsShape,
22
+ isLegacyRecoveryEventShape,
23
+ legacyRecoveryMigrationId,
24
+ } from "./task-recovery-migration.js";
17
25
 
18
26
  const EVENT_SCHEMA_VERSION = 1;
19
27
  export const LIFECYCLE_MILESTONES = Object.freeze([
@@ -40,6 +48,50 @@ const REPEATABLE_MILESTONES = new Set([
40
48
  "TERMINAL_RESULT_RECORDED",
41
49
  ]);
42
50
 
51
+ function eventIndexPath(eventsPath) {
52
+ return `${eventsPath}.index.json`;
53
+ }
54
+
55
+ function parseEventIndex(text, relativePath) {
56
+ if (typeof text !== "string") return null;
57
+ try {
58
+ const value = JSON.parse(text);
59
+ if (!value || value.schemaVersion !== 1 || !Number.isInteger(value.seq) || value.seq < 0
60
+ || (value.lastHash !== null && !/^[a-f0-9]{64}$/.test(value.lastHash))) return null;
61
+ return value;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function checkpointFromEvents(events) {
68
+ const last = events.at(-1) ?? null;
69
+ return {
70
+ schemaVersion: 1,
71
+ seq: last?.seq ?? 0,
72
+ lastHash: last?.hash ?? null,
73
+ };
74
+ }
75
+
76
+ async function readEventCheckpoint(target, packageRoot, relPath, options, transaction) {
77
+ if (!transaction.eventCheckpoints) transaction.eventCheckpoints = new Map();
78
+ const cached = transaction.eventCheckpoints.get(relPath);
79
+ if (cached) return cached;
80
+
81
+ const indexPath = eventIndexPath(relPath);
82
+ const indexed = parseEventIndex(await transaction.readText(indexPath), indexPath);
83
+ const tail = await readEventTail(target, packageRoot, { ...options, eventsPath: relPath, limit: 1 });
84
+ const last = tail.at(-1) ?? null;
85
+ let checkpoint;
86
+ if (indexed && indexed.seq === (last?.seq ?? 0) && indexed.lastHash === (last?.hash ?? null)) {
87
+ checkpoint = indexed;
88
+ } else {
89
+ checkpoint = checkpointFromEvents(await readEvents(target, packageRoot, { ...options, eventsPath: relPath }));
90
+ }
91
+ transaction.eventCheckpoints.set(relPath, checkpoint);
92
+ return checkpoint;
93
+ }
94
+
43
95
  export function validateKnownEventDetails(event) {
44
96
  if (!event || typeof event !== "object") return;
45
97
  switch (event.event) {
@@ -52,11 +104,63 @@ export function validateKnownEventDetails(event) {
52
104
  case "CHECKPOINT_RECONCILED":
53
105
  assertReconcileClosureDetails(event.details);
54
106
  return;
107
+ case "TASK_RECOVERY_RECORDED":
108
+ assertRecoveryRecordedDetails(event.details);
109
+ return;
110
+ case "OPERATOR_RECOVERY_RECORDED":
111
+ // The exact known legacy defect signature is tolerated here so the
112
+ // ledger can be parsed and classified. It only becomes valid through an
113
+ // official migration event (enforced by validateEventLedger).
114
+ if (!event.details?.recoveryId && isLegacyRecoveryDetailsShape(event.details)) return;
115
+ assertRecoveryRecordedDetails(event.details);
116
+ return;
117
+ case "LEGACY_RECOVERY_MIGRATION_RECORDED":
118
+ assertLegacyMigrationDetails(event.details);
119
+ return;
120
+ case "TASK_RECOVERY_RESUMED":
121
+ assertRecoveryResumedDetails(event.details);
122
+ return;
55
123
  default:
56
124
  return;
57
125
  }
58
126
  }
59
127
 
128
+ function assertStringList(value, label) {
129
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
130
+ throw protocolError("E_EVENT_INVALID", `${label} must be an array of non-empty strings`);
131
+ }
132
+ }
133
+
134
+ function assertRecoveryRecordedDetails(details) {
135
+ if (!details || typeof details !== "object" || Array.isArray(details)) {
136
+ throw protocolError("E_EVENT_INVALID", "recovery event requires structured details");
137
+ }
138
+ for (const key of ["recoveryId", "classification", "previousPhase", "authorityKind"]) {
139
+ if (typeof details[key] !== "string" || !details[key]) {
140
+ throw protocolError("E_EVENT_INVALID", `recovery event details.${key} must be a non-empty string`);
141
+ }
142
+ }
143
+ if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
144
+ throw protocolError("E_EVENT_INVALID", "recovery event details.previousRevision must be a non-negative integer");
145
+ }
146
+ if (!["STALE", "ABANDONED"].includes(details.classification)) {
147
+ throw protocolError("E_EVENT_INVALID", "recovery event details.classification must be STALE or ABANDONED");
148
+ }
149
+ if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(details.authorityKind)) {
150
+ throw protocolError("E_EVENT_INVALID", "recovery event details.authorityKind is invalid");
151
+ }
152
+ assertStringList(details.reasonCodes, "recovery event details.reasonCodes");
153
+ assertStringList(details.releasedClaims, "recovery event details.releasedClaims");
154
+ }
155
+
156
+ function assertRecoveryResumedDetails(details) {
157
+ if (!details || typeof details !== "object" || Array.isArray(details)
158
+ || typeof details.recoveryId !== "string" || !details.recoveryId) {
159
+ throw protocolError("E_EVENT_INVALID", "TASK_RECOVERY_RESUMED requires details.recoveryId");
160
+ }
161
+ assertStringList(details.reacquiredClaims, "TASK_RECOVERY_RESUMED details.reacquiredClaims");
162
+ }
163
+
60
164
  function assertReconcileClosureDetails(details) {
61
165
  if (!details || typeof details !== "object" || Array.isArray(details)) {
62
166
  throw protocolError("E_EVENT_INVALID", "CHECKPOINT_RECONCILED requires structured details");
@@ -76,7 +180,7 @@ function assertReconcileClosureDetails(details) {
76
180
  }
77
181
  }
78
182
 
79
- function eventHash(event) {
183
+ export function eventHash(event) {
80
184
  const { hash, ...body } = event;
81
185
  return canonicalFingerprint(body);
82
186
  }
@@ -94,6 +198,47 @@ export async function readEvents(target, packageRoot, options = {}) {
94
198
  const eventsPath = ensureWithin(target, relPath);
95
199
  if (!(await fileExists(eventsPath))) return [];
96
200
  const text = await readFile(eventsPath, "utf8");
201
+ return parseEventsText(text, relPath, packageRoot);
202
+ }
203
+
204
+ export async function readEventTail(target, packageRoot, options = {}) {
205
+ const limit = options.limit ?? 50;
206
+ if (!Number.isInteger(limit) || limit < 1) {
207
+ throw protocolError("E_EVENT_INVALID", "ledger tail limit must be a positive integer");
208
+ }
209
+ const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
210
+ await assertSafePath(target, relPath);
211
+ const eventsPath = ensureWithin(target, relPath);
212
+ if (!(await fileExists(eventsPath))) return [];
213
+ const size = (await stat(eventsPath)).size;
214
+ let window = Math.min(size, 64 * 1024);
215
+ while (true) {
216
+ const position = size - window;
217
+ const handle = await open(eventsPath, "r");
218
+ const bytes = Buffer.alloc(window);
219
+ try {
220
+ await handle.read(bytes, 0, window, position);
221
+ } finally {
222
+ await handle.close();
223
+ }
224
+ let text = bytes.toString("utf8");
225
+ if (position > 0) {
226
+ const firstLineEnd = text.indexOf("\n");
227
+ if (firstLineEnd < 0) {
228
+ window = Math.min(size, window * 2);
229
+ continue;
230
+ }
231
+ text = text.slice(firstLineEnd + 1);
232
+ }
233
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
234
+ if (lines.length >= limit || position === 0) {
235
+ return parseEventsText(lines.slice(-limit).join("\n"), relPath, packageRoot);
236
+ }
237
+ window = Math.min(size, window * 2);
238
+ }
239
+ }
240
+
241
+ async function parseEventsText(text, relPath, packageRoot) {
97
242
  assertJsonBytes(text, relPath);
98
243
  const schema = await readSchema("event", packageRoot);
99
244
  const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
@@ -112,20 +257,28 @@ export async function readEvents(target, packageRoot, options = {}) {
112
257
  }
113
258
 
114
259
  export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
260
+ const activeTransaction = getActiveTaskTransaction();
115
261
  if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
116
262
  if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
117
263
  const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
118
- const events = await readEvents(target, packageRoot, { ...options, eventsPath: relPath });
119
- const previous = events.at(-1) ?? null;
264
+ if (!activeTransaction) {
265
+ return withTaskTransaction({
266
+ target,
267
+ taskId: options.taskId ?? input.taskId,
268
+ lockTaskId: relPath === ARTIFACT_PATHS.events ? "__legacy-events__" : (options.taskId ?? input.taskId),
269
+ operation: "append-event",
270
+ }, async () => appendProtocolEvent(target, input, packageRoot, options));
271
+ }
272
+ const checkpoint = await readEventCheckpoint(target, packageRoot, relPath, options, activeTransaction);
120
273
  const event = {
121
- seq: events.length + 1,
274
+ seq: checkpoint.seq + 1,
122
275
  schemaVersion: EVENT_SCHEMA_VERSION,
123
276
  protocolVersion: PROTOCOL_VERSION,
124
277
  taskId: input.taskId,
125
278
  event: input.event,
126
279
  at: input.at ?? new Date().toISOString(),
127
280
  ...(input.fingerprint ? { fingerprint: input.fingerprint } : {}),
128
- previousHash: previous?.hash ?? null,
281
+ previousHash: checkpoint.lastHash,
129
282
  ...(input.details ? { details: structuredClone(input.details) } : {}),
130
283
  };
131
284
  validateKnownEventDetails(event);
@@ -133,14 +286,88 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
133
286
  const schema = await readSchema("event", packageRoot);
134
287
  assertSchema(event, schema, relPath);
135
288
  event.hash = eventHash(event);
136
- const eventsPath = ensureWithin(target, relPath);
137
289
  if (!options.dryRun) {
138
- await mkdir(path.dirname(eventsPath), { recursive: true });
139
- await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
290
+ if (activeTransaction) {
291
+ await activeTransaction.appendText(relPath, `${JSON.stringify(event)}\n`);
292
+ const nextCheckpoint = { schemaVersion: 1, seq: event.seq, lastHash: event.hash };
293
+ await activeTransaction.stageText(eventIndexPath(relPath), `${JSON.stringify(nextCheckpoint)}\n`);
294
+ activeTransaction.eventCheckpoints.set(relPath, nextCheckpoint);
295
+ } else {
296
+ const eventsPath = ensureWithin(target, relPath);
297
+ await mkdir(path.dirname(eventsPath), { recursive: true });
298
+ await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
299
+ }
140
300
  }
141
301
  return event;
142
302
  }
143
303
 
304
+ /**
305
+ * Validates the append-only pairing between unmigrated legacy recovery events
306
+ * and their official migration events. Strict by default; the official repair
307
+ * command validates intermediate state with `allowUnmigratedLegacyRecoveryEvents`
308
+ * before appending the migration events.
309
+ */
310
+ function validateLegacyRecoveryMigrations(events, errors, { allowUnmigratedLegacyRecoveryEvents = false } = {}) {
311
+ const migrationBySeq = new Map();
312
+ for (const event of events) {
313
+ if (event.event !== LEGACY_RECOVERY_MIGRATION_EVENT) continue;
314
+ try {
315
+ assertLegacyMigrationDetails(event.details);
316
+ } catch (err) {
317
+ errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
318
+ continue;
319
+ }
320
+ if (migrationBySeq.has(event.details.legacyEventSeq)) {
321
+ errors.push({
322
+ code: "E_EVENT_INVALID",
323
+ message: `event ${event.seq} (${event.event}): duplicate migration for legacy recovery event seq ${event.details.legacyEventSeq}`,
324
+ });
325
+ continue;
326
+ }
327
+ migrationBySeq.set(event.details.legacyEventSeq, event);
328
+ }
329
+ for (const event of events) {
330
+ if (!isLegacyRecoveryEventShape(event)) continue;
331
+ const migration = migrationBySeq.get(event.seq);
332
+ if (!migration) {
333
+ if (!allowUnmigratedLegacyRecoveryEvents) {
334
+ errors.push({
335
+ code: "E_EVENT_INVALID",
336
+ message: `legacy recovery event ${event.seq} is not officially migrated (run forgeloop task-repair-legacy-recovery)`,
337
+ });
338
+ }
339
+ continue;
340
+ }
341
+ migrationBySeq.delete(event.seq);
342
+ const expectedRecoveryId = legacyRecoveryMigrationId({ taskId: event.taskId, seq: event.seq, hash: event.hash });
343
+ // Tail-binding: the migration event is appended at the ledger tail and may
344
+ // sit anywhere after its historical source. It binds by reference only.
345
+ if (migration.seq <= event.seq) {
346
+ errors.push({
347
+ code: "E_EVENT_INVALID",
348
+ message: `migration event ${migration.seq} must follow legacy recovery event ${event.seq}`,
349
+ });
350
+ }
351
+ if (migration.taskId !== event.taskId
352
+ || migration.details.legacyTaskId !== event.taskId
353
+ || migration.details.recoveryId !== expectedRecoveryId
354
+ || migration.details.legacyEventHash !== event.hash
355
+ || migration.details.legacyEventAt !== event.at
356
+ || migration.details.legacyEventType !== event.event) {
357
+ errors.push({
358
+ code: "E_LEDGER_HASH_INVALID",
359
+ message: `migration event ${migration.seq} does not bind legacy recovery event ${event.seq}`,
360
+ });
361
+ }
362
+ }
363
+ for (const [legacySeq, migration] of migrationBySeq) {
364
+ errors.push({
365
+ code: "E_EVENT_INVALID",
366
+ message: `migration event ${migration.seq} references unknown legacy recovery event seq ${legacySeq}`,
367
+ });
368
+ }
369
+ }
370
+
144
371
  export async function validateEventLedger(target, packageRoot, options = {}) {
145
372
  const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
146
373
  let events;
@@ -222,6 +449,9 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
222
449
  errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
223
450
  }
224
451
  }
452
+ validateLegacyRecoveryMigrations(events, errors, {
453
+ allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
454
+ });
225
455
  return { valid: errors.length === 0, events, errors };
226
456
  }
227
457
 
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import path from "node:path";
@@ -54,25 +54,79 @@ function validateAuthorityBeforeLaunch({ target, taskId, argv, resolution, detai
54
54
  }
55
55
  }
56
56
 
57
- function executeProcess(argv, cwd) {
57
+ const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
58
+ export const TERMINATION_GRACE_MS = 1_000;
59
+
60
+ function digest(bytes) {
61
+ return createHash("sha256").update(bytes).digest("hex");
62
+ }
63
+
64
+ function executeProcess(argv, cwd, { timeoutMs = null } = {}) {
58
65
  return new Promise((resolve) => {
59
66
  let spawnError = null;
67
+ let timedOut = false;
68
+ let settled = false;
69
+ let timeout = null;
70
+ let forceTermination = null;
71
+ const stdout = [];
72
+ const stderr = [];
73
+ let stdoutBytes = 0;
74
+ let stderrBytes = 0;
75
+ let outputTruncated = false;
76
+ const capture = (chunks, chunk, total) => {
77
+ const available = MAX_CAPTURED_OUTPUT_BYTES - total;
78
+ if (available <= 0) {
79
+ outputTruncated = true;
80
+ return total;
81
+ }
82
+ if (chunk.length > available) {
83
+ chunks.push(chunk.subarray(0, available));
84
+ outputTruncated = true;
85
+ return total + available;
86
+ }
87
+ chunks.push(chunk);
88
+ return total + chunk.length;
89
+ };
90
+ const finish = (result) => {
91
+ if (settled) return;
92
+ settled = true;
93
+ if (timeout) clearTimeout(timeout);
94
+ if (forceTermination) clearTimeout(forceTermination);
95
+ resolve({
96
+ ...result,
97
+ timedOut,
98
+ stdout: Buffer.concat(stdout),
99
+ stderr: Buffer.concat(stderr),
100
+ stdoutBytes,
101
+ stderrBytes,
102
+ outputTruncated,
103
+ });
104
+ };
60
105
  try {
61
106
  const child = spawn(argv[0], argv.slice(1), {
62
107
  cwd,
63
108
  shell: false,
64
109
  stdio: ["ignore", "pipe", "pipe"],
65
110
  });
66
- child.stdout?.resume();
67
- child.stderr?.resume();
111
+ child.stdout?.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes); });
112
+ child.stderr?.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes); });
68
113
  child.once("error", (error) => {
69
114
  spawnError = error;
70
115
  });
71
- child.once("close", (exitCode) => {
72
- resolve({ exitCode, spawnError });
116
+ child.once("close", (exitCode, signal) => {
117
+ finish({ exitCode, signal, spawnError });
73
118
  });
119
+ if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
120
+ timeout = setTimeout(() => {
121
+ timedOut = true;
122
+ child.kill("SIGTERM");
123
+ forceTermination = setTimeout(() => {
124
+ child.kill("SIGKILL");
125
+ }, TERMINATION_GRACE_MS);
126
+ }, timeoutMs);
127
+ }
74
128
  } catch (error) {
75
- resolve({ exitCode: null, spawnError: error });
129
+ finish({ exitCode: null, signal: null, spawnError: error });
76
130
  }
77
131
  });
78
132
  }
@@ -106,6 +160,7 @@ export async function runCommandExecution({
106
160
  authorityContext,
107
161
  runtimeContext,
108
162
  executionPath,
163
+ timeoutMs = null,
109
164
  } = {}) {
110
165
  const commandArgv = normalizeArgv(argv);
111
166
  const resolution = await resolveExecutionResolution({
@@ -145,7 +200,7 @@ export async function runCommandExecution({
145
200
 
146
201
  const executionId = `exec-${randomUUID()}`;
147
202
  const startedAt = new Date().toISOString();
148
- const processResult = await executeProcess(commandArgv, target);
203
+ const processResult = await executeProcess(commandArgv, target, { timeoutMs });
149
204
  const finishedAt = new Date().toISOString();
150
205
  const execution = {
151
206
  schemaVersion: 1,
@@ -167,8 +222,17 @@ export async function runCommandExecution({
167
222
  ...(resolution.dispatch ? { dispatch: resolution.dispatch } : {}),
168
223
  startedAt,
169
224
  finishedAt,
170
- status: processResult.exitCode === 0 && !processResult.spawnError ? "passed" : "failed",
225
+ status: processResult.exitCode === 0 && !processResult.spawnError && !processResult.timedOut ? "passed" : "failed",
171
226
  exitCode: processResult.exitCode,
227
+ durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
228
+ termination: processResult.spawnError ? "spawn-error" : processResult.timedOut ? "timeout" : processResult.signal ? "signal" : "exit",
229
+ signal: processResult.signal ?? null,
230
+ stdoutSha256: digest(processResult.stdout),
231
+ stderrSha256: digest(processResult.stderr),
232
+ stdoutBytes: processResult.stdoutBytes,
233
+ stderrBytes: processResult.stderrBytes,
234
+ outputTruncated: processResult.outputTruncated,
235
+ ...(Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeoutMs, terminationGraceMs: TERMINATION_GRACE_MS } : {}),
172
236
  };
173
237
  const execPath = executionPath ?? await resolveExecutionArtifactPath(target, taskId, executionId);
174
238
  const written = await writeJsonArtifact(target, execPath, execution, "execution", packageRoot);
@@ -3,13 +3,62 @@ import {
3
3
  access,
4
4
  lstat,
5
5
  mkdir,
6
+ open,
6
7
  readFile,
7
8
  realpath,
8
9
  rename,
9
10
  unlink,
10
- writeFile,
11
11
  } from "node:fs/promises";
12
12
  import path from "node:path";
13
+ import { setTimeout as delay } from "node:timers/promises";
14
+
15
+ const WINDOWS_TRANSIENT_RETRY_DELAYS_MS = Object.freeze([5, 10, 20, 40]);
16
+
17
+ async function fsCallWithTransientWindowsRetry(fsImpl, filePath, {
18
+ platform = process.platform,
19
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
20
+ delayImpl = delay,
21
+ } = {}) {
22
+ let retryIndex = 0;
23
+ while (true) {
24
+ try {
25
+ return await fsImpl(filePath);
26
+ } catch (error) {
27
+ const retryable = platform === "win32"
28
+ && (error?.code === "EPERM" || error?.code === "EACCES")
29
+ && retryIndex < retryDelaysMs.length;
30
+ if (!retryable) throw error;
31
+ await delayImpl(retryDelaysMs[retryIndex]);
32
+ retryIndex += 1;
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function realpathWithTransientWindowsRetry(filePath, {
38
+ platform = process.platform,
39
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
40
+ realpathImpl = realpath,
41
+ delayImpl = delay,
42
+ } = {}) {
43
+ return fsCallWithTransientWindowsRetry(realpathImpl, filePath, {
44
+ platform,
45
+ retryDelaysMs,
46
+ delayImpl,
47
+ });
48
+ }
49
+
50
+ export function lstatWithTransientWindowsRetry(filePath, {
51
+ platform = process.platform,
52
+ retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
53
+ lstatImpl = lstat,
54
+ delayImpl = delay,
55
+ } = {}) {
56
+ return fsCallWithTransientWindowsRetry(lstatImpl, filePath, {
57
+ platform,
58
+ retryDelaysMs,
59
+ delayImpl,
60
+ });
61
+ }
13
62
 
14
63
  export function ensureWithin(root, relativePath) {
15
64
  if (path.isAbsolute(relativePath)) {
@@ -27,7 +76,7 @@ export function ensureWithin(root, relativePath) {
27
76
  export async function assertSafePath(root, relativePath) {
28
77
  const destination = ensureWithin(root, relativePath);
29
78
  const absoluteRoot = path.resolve(root);
30
- const rootInfo = await lstat(absoluteRoot);
79
+ const rootInfo = await lstatWithTransientWindowsRetry(absoluteRoot);
31
80
  if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
32
81
  throw new Error(`Target directory must not be a symlink: ${absoluteRoot}`);
33
82
  }
@@ -37,7 +86,7 @@ export async function assertSafePath(root, relativePath) {
37
86
  for (const segment of segments) {
38
87
  current = path.join(current, segment);
39
88
  try {
40
- const info = await lstat(current);
89
+ const info = await lstatWithTransientWindowsRetry(current);
41
90
  if (info.isSymbolicLink()) {
42
91
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
43
92
  }
@@ -50,12 +99,12 @@ export async function assertSafePath(root, relativePath) {
50
99
  let existing = destination;
51
100
  while (true) {
52
101
  try {
53
- const info = await lstat(existing);
102
+ const info = await lstatWithTransientWindowsRetry(existing);
54
103
  if (info.isSymbolicLink()) {
55
104
  throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
56
105
  }
57
- const resolvedRoot = await realpath(absoluteRoot);
58
- const resolvedExisting = await realpath(existing);
106
+ const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
107
+ const resolvedExisting = await realpathWithTransientWindowsRetry(existing);
59
108
  const relativeResolved = path.relative(resolvedRoot, resolvedExisting);
60
109
  if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
61
110
  throw new Error(`Path escapes target directory: ${relativePath}`);
@@ -108,10 +157,28 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
108
157
 
109
158
  await mkdir(path.dirname(filePath), { recursive: true });
110
159
  const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
160
+ let temporaryHandle;
111
161
  try {
112
- await writeFile(temporaryPath, bytes, { mode: 0o644 });
162
+ // A rename alone is atomic but does not guarantee that staged bytes have
163
+ // reached stable storage. Sync the temporary file before publishing it;
164
+ // directory sync is best-effort because Windows and some filesystems do
165
+ // not permit opening a directory for fsync.
166
+ temporaryHandle = await open(temporaryPath, "w", 0o644);
167
+ await temporaryHandle.writeFile(bytes);
168
+ await temporaryHandle.sync();
169
+ await temporaryHandle.close();
170
+ temporaryHandle = null;
113
171
  await rename(temporaryPath, filePath);
172
+ try {
173
+ const directoryHandle = await open(path.dirname(filePath), "r");
174
+ try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
175
+ } catch (error) {
176
+ if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP", "UNKNOWN"].includes(error.code)) throw error;
177
+ }
114
178
  } catch (error) {
179
+ if (temporaryHandle) {
180
+ try { await temporaryHandle.close(); } catch { /* preserve original error */ }
181
+ }
115
182
  try {
116
183
  await unlink(temporaryPath);
117
184
  } catch {
@@ -119,4 +186,4 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
119
186
  }
120
187
  throw error;
121
188
  }
122
- }
189
+ }
@@ -11,6 +11,7 @@ import { FORGELOOP_KIT_DIR } from "./target-layout.js";
11
11
  import { trustedAuthorityConfiguration } from "./trusted-authority.js";
12
12
  import { reconcileContinuity } from "./continuity-reconciliation.js";
13
13
  import { continuityFinding, continuityIsHealthy } from "./continuity-observability.js";
14
+ import { findTaskById } from "./task-discovery.js";
14
15
 
15
16
  function profileMetadata(bytes) {
16
17
  const text = bytes.toString("utf8");
@@ -40,6 +41,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
40
41
  const statePath = ensureWithin(target, effectiveStateRel);
41
42
  const statePresent = await fileExists(statePath);
42
43
  const state = await readAndClassifyWorkState({ target, packageRoot, contractFile, taskId, stateFile: effectiveStateRel });
44
+ const taskInfo = taskId ? await findTaskById(target, taskId, packageRoot) : null;
43
45
  const continuity = await reconcileContinuity({ target, packageRoot, taskId });
44
46
  const schemaRoot = manifest?.layoutVersion >= 2
45
47
  ? ensureWithin(target, FORGELOOP_KIT_DIR)
@@ -78,6 +80,21 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
78
80
  const continuityIssue = continuityFinding(continuity);
79
81
  if (continuityIssue) findings.push(continuityIssue);
80
82
 
83
+ if (taskInfo?.ownershipValid === false) {
84
+ findings.push({
85
+ code: "task-claim-ownership-inconsistent",
86
+ severity: "error",
87
+ path: taskArtifactPath(taskId, "recovery"),
88
+ message: "Task claim ownership cannot be validated from recovery state and ledger history.",
89
+ remediation: `Run forgeloop validate-protocol --task ${taskId} --json and repair the reported protocol-owned artifact.`,
90
+ evidence: createEvidence({
91
+ kind: "BLOCKED",
92
+ source: taskArtifactPath(taskId, "events"),
93
+ result: "E_TASK_CLAIM_OWNERSHIP_INCONSISTENT",
94
+ }),
95
+ });
96
+ }
97
+
81
98
  if (state.status === "INVALID") {
82
99
  findings.push({
83
100
  code: "state-invalid",
@@ -132,6 +149,15 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
132
149
  evidence: protocolEvidence,
133
150
  },
134
151
  state: { ...state, path: WORK_STATE_PATH, present: statePresent },
152
+ recovery: taskInfo?.recovery ?? null,
153
+ claims: taskInfo ? {
154
+ state: taskInfo.claimState,
155
+ historical: taskInfo.historicalWriteClaims,
156
+ effective: taskInfo.effectiveWriteClaims,
157
+ mutationAllowed: taskInfo.mutationAllowed,
158
+ ownershipValid: taskInfo.ownershipValid,
159
+ ownershipErrors: taskInfo.ownershipErrors ?? taskInfo.errors ?? [],
160
+ } : null,
135
161
  continuity,
136
162
  compatibility: {
137
163
  deprecated: true,
@@ -142,6 +168,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
142
168
  ok: doctor.ok
143
169
  && !manifestError
144
170
  && schemaHealth.status === "valid"
171
+ && taskInfo?.ownershipValid !== false
145
172
  && !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
146
173
  && continuityIsHealthy(continuity),
147
174
  };