@fitlab-ai/agent-infra 0.11.5 → 0.11.7

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 (155) hide show
  1. package/dist/lib/git/primary-worktree.js +58 -0
  2. package/dist/lib/merge.js +62 -10
  3. package/dist/lib/platform/github-provider.js +9 -14
  4. package/dist/lib/platform/github-release-notes.js +80 -11
  5. package/dist/lib/platform/none-provider.js +1 -1
  6. package/dist/lib/platform/provider-contract.d.ts +39 -16
  7. package/dist/lib/platform/provider-contract.js +1 -1
  8. package/dist/lib/platform/provider-validation.js +83 -13
  9. package/dist/lib/platform/pull-requests.js +29 -3
  10. package/dist/lib/platform/release-notes.js +7 -8
  11. package/dist/lib/sandbox/capture.js +1 -1
  12. package/dist/lib/sandbox/commands/create.js +46 -1
  13. package/dist/lib/sandbox/commands/enter.js +1 -1
  14. package/dist/lib/sandbox/commands/start.js +1 -1
  15. package/dist/lib/sandbox/config.js +10 -0
  16. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  17. package/dist/lib/sandbox/control/server.js +6 -2
  18. package/dist/lib/sandbox/task-resolver.js +62 -2
  19. package/dist/lib/server/config.js +7 -27
  20. package/dist/lib/server/daemon.js +4 -3
  21. package/dist/lib/server/process-control.js +4 -2
  22. package/dist/lib/server/runner.js +4 -3
  23. package/dist/lib/task/artifact-lifecycle.js +42 -10
  24. package/dist/lib/task/artifact-receipts.js +62 -22
  25. package/dist/lib/task/capabilities.js +22 -4
  26. package/dist/lib/task/completion-facts.js +56 -0
  27. package/dist/lib/task/create-service.js +6 -2
  28. package/dist/lib/task/create.js +46 -82
  29. package/dist/lib/task/events.js +268 -195
  30. package/dist/lib/task/finalization-handoff.js +2 -1
  31. package/dist/lib/task/invalidation-command.js +4 -2
  32. package/dist/lib/task/invalidation.js +38 -9
  33. package/dist/lib/task/local-artifact-finalization.js +17 -3
  34. package/dist/lib/task/qualification-audit.js +23 -105
  35. package/dist/lib/task/resolve-ref.js +3 -3
  36. package/dist/lib/task/review-finalization.js +7 -0
  37. package/dist/lib/task/review-identity.js +69 -0
  38. package/dist/lib/task/sections.js +6 -5
  39. package/dist/lib/task/task-execution-lock.js +6 -3
  40. package/dist/lib/task/verification-engine.js +14 -63
  41. package/dist/lib/task/verification.js +35 -38
  42. package/dist/lib/task/write.js +121 -1
  43. package/dist/lifecycle-build-manifest.json +3 -3
  44. package/lib/git/primary-worktree.ts +62 -0
  45. package/lib/merge.ts +59 -10
  46. package/lib/platform/github-provider.ts +11 -18
  47. package/lib/platform/github-release-notes.ts +89 -14
  48. package/lib/platform/none-provider.ts +1 -1
  49. package/lib/platform/provider-contract.ts +38 -12
  50. package/lib/platform/provider-validation.ts +83 -12
  51. package/lib/platform/pull-requests.ts +27 -3
  52. package/lib/platform/release-notes.ts +11 -12
  53. package/lib/sandbox/capture.ts +1 -1
  54. package/lib/sandbox/commands/create.ts +52 -1
  55. package/lib/sandbox/commands/enter.ts +1 -1
  56. package/lib/sandbox/commands/start.ts +1 -1
  57. package/lib/sandbox/config.ts +12 -0
  58. package/lib/sandbox/control/lifecycle.ts +22 -13
  59. package/lib/sandbox/control/server.ts +5 -2
  60. package/lib/sandbox/task-resolver.ts +51 -2
  61. package/lib/server/config.ts +7 -29
  62. package/lib/server/daemon.ts +5 -3
  63. package/lib/server/process-control.ts +4 -2
  64. package/lib/server/runner.ts +7 -4
  65. package/lib/server/server.schema.json +0 -4
  66. package/lib/task/artifact-lifecycle.ts +35 -11
  67. package/lib/task/artifact-receipts.ts +55 -15
  68. package/lib/task/capabilities.ts +17 -3
  69. package/lib/task/completion-facts.ts +66 -0
  70. package/lib/task/create-service.ts +7 -3
  71. package/lib/task/create.ts +44 -71
  72. package/lib/task/events.ts +248 -182
  73. package/lib/task/finalization-handoff.ts +2 -1
  74. package/lib/task/invalidation-command.ts +4 -2
  75. package/lib/task/invalidation.ts +35 -10
  76. package/lib/task/local-artifact-finalization.ts +13 -3
  77. package/lib/task/qualification-audit.ts +21 -114
  78. package/lib/task/resolve-ref.ts +3 -3
  79. package/lib/task/review-finalization.ts +7 -0
  80. package/lib/task/review-identity.ts +85 -0
  81. package/lib/task/sections.ts +6 -6
  82. package/lib/task/task-execution-lock.ts +7 -3
  83. package/lib/task/verification-engine.ts +13 -86
  84. package/lib/task/verification.ts +34 -38
  85. package/lib/task/write.ts +115 -1
  86. package/package.json +1 -1
  87. package/templates/.agents/rules/README.en.md +1 -0
  88. package/templates/.agents/rules/README.zh-CN.md +1 -0
  89. package/templates/.agents/rules/decision-qualification.en.md +4 -3
  90. package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
  91. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  92. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  93. package/templates/.agents/rules/validation-output.en.md +19 -0
  94. package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
  95. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  96. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
  97. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  98. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  99. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  100. package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
  101. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
  102. package/templates/.agents/skills/cancel-task/SKILL.en.md +2 -0
  103. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
  104. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  105. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  106. package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
  107. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
  108. package/templates/.agents/skills/commit/SKILL.en.md +1 -1
  109. package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
  110. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
  111. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
  112. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
  113. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
  114. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  115. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
  116. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  117. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
  118. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  119. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  120. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
  121. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  122. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
  123. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  124. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
  125. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  126. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  127. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
  128. package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
  129. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
  130. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
  131. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
  132. package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
  133. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
  134. package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
  135. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
  136. package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
  137. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
  138. package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
  139. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
  140. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
  141. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
  142. package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
  143. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
  144. package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
  145. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
  146. package/templates/.agents/skills/review-pr/SKILL.en.md +2 -0
  147. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +2 -0
  148. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  149. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  150. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  151. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  152. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
  153. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
  154. package/templates/.agents/templates/task.en.md +2 -2
  155. package/templates/.agents/templates/task.zh-CN.md +2 -2
@@ -1298,6 +1298,10 @@ export async function quiesceSandboxControlRoot(
1298
1298
  timeoutMs?: number;
1299
1299
  timing?: SandboxControlTimingPolicy;
1300
1300
  identityProbe?: ProcessIdentityProbe;
1301
+ executionProcessControl?: Readonly<{
1302
+ isAlive: (execution: SandboxControlExecution) => boolean;
1303
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => boolean;
1304
+ }>;
1301
1305
  } = {}
1302
1306
  ): Promise<'missing' | 'stale' | 'stopped'> {
1303
1307
  const resolvedRoot = path.resolve(root);
@@ -1338,58 +1342,63 @@ export async function quiesceSandboxControlRoot(
1338
1342
  if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
1339
1343
 
1340
1344
  const platform = options.platform ?? process.platform;
1345
+ const processControl = options.executionProcessControl ?? {
1346
+ isAlive: (execution: SandboxControlExecution) => executionAlive(execution, platform, identityProbe),
1347
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => phase === 'graceful'
1348
+ ? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
1349
+ : terminateSandboxControlExecution(execution, {
1350
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1351
+ })
1352
+ };
1341
1353
  let executions = manifest ? readExecutions(manifest) : [];
1342
1354
  const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
1343
1355
  if (!owner) {
1344
1356
  if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
1345
1357
  for (const execution of executions) {
1346
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1358
+ processControl.terminate(execution, 'graceful');
1347
1359
  }
1348
1360
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1349
1361
  while (Date.now() < softDeadlineAt) {
1350
- if (!executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1362
+ if (!executions.some((execution) => processControl.isAlive(execution))) break;
1351
1363
  await new Promise((resolve) => setTimeout(resolve, 25));
1352
1364
  }
1353
1365
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1354
1366
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1355
1367
  for (const execution of executions) {
1356
- if (!terminateSandboxControlExecution(execution, {
1357
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1358
- })) {
1368
+ if (!processControl.terminate(execution, 'force')) {
1359
1369
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
1360
1370
  }
1361
1371
  }
1362
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1372
+ if (executions.some((execution) => processControl.isAlive(execution))) {
1363
1373
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1364
1374
  }
1365
1375
  return broker || status ? 'stale' : 'missing';
1366
1376
  }
1367
1377
 
1368
- const remaining = (): number => Math.max(0, deadlineAt - Date.now());
1369
1378
  signalOwner(owner, platform, false);
1370
1379
  for (const execution of executions) {
1371
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1380
+ processControl.terminate(execution, 'graceful');
1372
1381
  }
1373
1382
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1374
1383
  while (Date.now() < softDeadlineAt) {
1375
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1384
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) break;
1376
1385
  await new Promise((resolve) => setTimeout(resolve, 25));
1377
1386
  }
1378
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1387
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
1379
1388
  return 'stopped';
1380
1389
  }
1381
1390
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1382
1391
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1383
1392
  for (const execution of executions) {
1384
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
1393
+ processControl.terminate(execution, 'force');
1385
1394
  }
1386
1395
  signalOwner(owner, platform, true);
1387
1396
  while (Date.now() < deadlineAt) {
1388
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) return 'stopped';
1397
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) return 'stopped';
1389
1398
  await new Promise((resolve) => setTimeout(resolve, 25));
1390
1399
  }
1391
1400
  if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
1392
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1401
+ if (executions.some((execution) => processControl.isAlive(execution))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1393
1402
  return 'stopped';
1394
1403
  }
1395
1404
 
@@ -272,12 +272,15 @@ function taskCreateDomainEvidence(
272
272
  ? output.task as Record<string, unknown> : null;
273
273
  const taskId = typeof task?.id === 'string' ? task.id : null;
274
274
  const shortId = typeof task?.shortId === 'string' ? task.shortId : null;
275
- if (!taskId || !shortId) return { consistent: false };
275
+ const state = task?.state;
276
+ if (!taskId || !['active', 'blocked', 'completed', 'archive'].includes(String(state))) return { consistent: false };
277
+ if (state === 'active' ? !shortId : output.status !== 'no-op' || shortId !== null) return { consistent: false };
276
278
  try {
277
279
  const resolved = resolveTaskRef(taskId, { repoRoot: manifest.repoRoot });
278
280
  const shortIds = loadShortIdByTaskId(manifest.repoRoot);
279
281
  return {
280
- consistent: resolved.ok && resolved.taskId === taskId && resolved.state === 'active' && shortIds.get(taskId) === shortId,
282
+ consistent: resolved.ok && resolved.taskId === taskId && resolved.state === state
283
+ && (state === 'active' ? shortIds.get(taskId) === shortId : !shortIds.has(taskId)),
281
284
  taskId,
282
285
  shortId
283
286
  };
@@ -36,7 +36,9 @@ function stripQuotes(value: string): string {
36
36
 
37
37
  function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
38
38
  for (const state of TASK_WORKSPACE_STATES) {
39
- const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
39
+ const taskPath = state === 'archive'
40
+ ? path.join(repoRoot, '.agents', 'workspace', state, ...findArchiveTaskParts(repoRoot, taskId), 'local', 'task.md')
41
+ : path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
40
42
  if (fs.existsSync(taskPath)) {
41
43
  return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
42
44
  }
@@ -45,7 +47,7 @@ function readTaskContent(repoRoot: string, taskId: string): { content: string; s
45
47
  }
46
48
 
47
49
  function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
48
- return TASK_WORKSPACE_STATES.flatMap((state) => {
50
+ const hot = TASK_WORKSPACE_STATES.filter((state) => state !== 'archive').flatMap((state) => {
49
51
  const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
50
52
  if (!fs.existsSync(taskMd)) return [];
51
53
  const content = fs.readFileSync(taskMd, 'utf8');
@@ -56,6 +58,32 @@ function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspac
56
58
  taskMd
57
59
  }];
58
60
  });
61
+ const parts = findArchiveTaskParts(repoRoot, taskId);
62
+ if (parts.length === 0) return hot;
63
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', 'archive', ...parts, 'local', 'task.md');
64
+ if (!fs.existsSync(taskMd)) return hot;
65
+ const content = fs.readFileSync(taskMd, 'utf8');
66
+ return [...hot, {
67
+ taskId,
68
+ branch: resolveBranchFromTaskContent(content, taskId),
69
+ state: 'archive',
70
+ taskMd
71
+ }];
72
+ }
73
+
74
+ function findArchiveTaskParts(repoRoot: string, taskId: string): string[] {
75
+ const archiveRoot = path.join(repoRoot, '.agents', 'workspace', 'archive');
76
+ if (!fs.existsSync(archiveRoot)) return [];
77
+ for (const year of fs.readdirSync(archiveRoot).sort().reverse()) {
78
+ if (!/^\d{4}$/.test(year)) continue;
79
+ for (const month of fs.readdirSync(path.join(archiveRoot, year)).sort().reverse()) {
80
+ if (!/^\d{2}$/.test(month)) continue;
81
+ for (const day of fs.readdirSync(path.join(archiveRoot, year, month)).sort().reverse()) {
82
+ if (/^\d{2}$/.test(day) && fs.existsSync(path.join(archiveRoot, year, month, day, taskId, 'local', 'task.md'))) return [year, month, day, taskId];
83
+ }
84
+ }
85
+ }
86
+ return [];
59
87
  }
60
88
 
61
89
  function resolveBranchFromTaskContent(content: string, taskId: string): string {
@@ -102,6 +130,27 @@ export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWork
102
130
 
103
131
  export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
104
132
  const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
133
+ if (state === 'archive') {
134
+ if (!fs.existsSync(stateRoot)) return [];
135
+ const rows: TaskWorkspace[] = [];
136
+ for (const year of fs.readdirSync(stateRoot).sort()) {
137
+ if (!/^\d{4}$/.test(year)) continue;
138
+ for (const month of fs.readdirSync(path.join(stateRoot, year)).sort()) {
139
+ if (!/^\d{2}$/.test(month)) continue;
140
+ for (const day of fs.readdirSync(path.join(stateRoot, year, month)).sort()) {
141
+ if (!/^\d{2}$/.test(day)) continue;
142
+ for (const taskId of fs.readdirSync(path.join(stateRoot, year, month, day)).sort()) {
143
+ if (!TASK_ID_RE.test(taskId)) continue;
144
+ const taskMd = path.join(stateRoot, year, month, day, taskId, 'local', 'task.md');
145
+ if (!fs.existsSync(taskMd)) continue;
146
+ const content = fs.readFileSync(taskMd, 'utf8');
147
+ rows.push({ taskId, branch: resolveBranchFromTaskContent(content, taskId), state, taskMd });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return rows;
153
+ }
105
154
  if (!fs.existsSync(stateRoot)) return [];
106
155
  return fs.readdirSync(stateRoot, { withFileTypes: true })
107
156
  .filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { createHash } from 'node:crypto';
5
4
  import { execFileSync } from 'node:child_process';
5
+ import { resolvePrimaryWorktreeRoot } from '../git/primary-worktree.ts';
6
6
 
7
7
  export type ServerLogConfig = {
8
8
  path: string;
@@ -52,17 +52,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
52
52
  return typeof value === 'object' && value !== null && !Array.isArray(value);
53
53
  }
54
54
 
55
- // Daemon runtime state (log + PID) lives OUTSIDE the repo, under the user's home
56
- // directory, keyed by the .airc.json "project" AND a stable hash of the repo
57
- // root path:
58
- // ~/.agent-infra/logs/<project>/<repo-hash>/server.log
59
- // ~/.agent-infra/run/<project>/<repo-hash>/server.pid
60
- // The <project> segment groups a project's checkouts for readability; the
61
- // <repo-hash> segment guarantees that two checkouts/worktrees of the same
62
- // project (same "project" but different absolute path) get ISOLATED runtime
63
- // dirs, so they never read/control each other's daemon. Using os.homedir() +
64
- // path.join keeps this correct on Windows too (C:\Users\<name>\.agent-infra\...).
65
- // An explicit log.path in server.json/.local/env still overrides the log default.
55
+ // Runtime state is shared by all worktrees through the primary checkout.
66
56
  function resolveProjectKey(repoRoot: string): string {
67
57
  try {
68
58
  const airc = JSON.parse(
@@ -77,13 +67,8 @@ function resolveProjectKey(repoRoot: string): string {
77
67
  return path.basename(repoRoot);
78
68
  }
79
69
 
80
- // Short, stable, filesystem-safe discriminator for a checkout's absolute path.
81
- function repoKey(repoRoot: string): string {
82
- return createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
83
- }
84
-
85
- function runtimePath(repoRoot: string, projectKey: string, kind: 'logs' | 'run', file: string): string {
86
- return path.join(homedir(), '.agent-infra', kind, projectKey, repoKey(repoRoot), file);
70
+ function runtimePath(projectKey: string, kind: 'logs' | 'run', file: string): string {
71
+ return path.join(homedir(), '.agent-infra', kind, projectKey, file);
87
72
  }
88
73
 
89
74
  function detectRepoRoot(): string {
@@ -185,7 +170,7 @@ export function validateServerConfig(committed: Record<string, unknown>): Server
185
170
  }
186
171
 
187
172
  export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): ServerConfig {
188
- const repoRoot = rootDir ?? detectRepoRoot();
173
+ const repoRoot = resolvePrimaryWorktreeRoot(rootDir ?? detectRepoRoot());
189
174
  const agentsDir = path.join(repoRoot, '.agents');
190
175
 
191
176
  const committed = readJsonIfPresent(path.join(agentsDir, 'server.json'));
@@ -205,18 +190,11 @@ export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): Server
205
190
  const projectKey = resolveProjectKey(repoRoot);
206
191
 
207
192
  const log = isPlainObject(merged.log) ? merged.log : {};
208
- // No explicit log.path → default under ~/.agent-infra/logs/<project>/.
209
- // Explicit relative path resolves against the repo root; absolute is used as-is.
210
- const explicitPath = typeof log.path === 'string' ? log.path : null;
211
- const resolvedLogPath = explicitPath === null
212
- ? runtimePath(repoRoot, projectKey, 'logs', 'server.log')
213
- : (path.isAbsolute(explicitPath) ? explicitPath : path.join(repoRoot, explicitPath));
214
-
215
193
  return {
216
194
  repoRoot,
217
- pidFile: runtimePath(repoRoot, projectKey, 'run', 'server.pid'),
195
+ pidFile: runtimePath(projectKey, 'run', 'server.pid'),
218
196
  log: {
219
- path: resolvedLogPath,
197
+ path: runtimePath(projectKey, 'logs', 'server.log'),
220
198
  rotateAtBytes: typeof log.rotateAtBytes === 'number' ? log.rotateAtBytes : DEFAULT_ROTATE_BYTES
221
199
  },
222
200
  heartbeatMs: typeof merged.heartbeatMs === 'number' ? merged.heartbeatMs : DEFAULT_SERVER_CONFIG.heartbeatMs,
@@ -83,7 +83,7 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
83
83
  chunkChars: typeof options.config.stream?.chunkChars === 'number' ? options.config.stream.chunkChars : 4000,
84
84
  throttleMs: typeof options.config.stream?.throttleMs === 'number' ? options.config.stream.throttleMs : 1500
85
85
  },
86
- (emit) => runAiImpl(plan.argv, { onChunk: emit }),
86
+ (emit) => runAiImpl(plan.argv, { onChunk: emit, cwd: options.config.repoRoot }),
87
87
  (outbound) => replyOutbound(message, outbound)
88
88
  );
89
89
  }
@@ -100,15 +100,17 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
100
100
  // - runDaemon() awaits a shutdown promise that only resolves once a
101
101
  // SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
102
102
  // the only keep-alive timer (that would let the process exit immediately).
103
- export async function runDaemon(): Promise<void> {
103
+ export async function runDaemon({ rootDir }: { rootDir?: string } = {}): Promise<void> {
104
104
  let config;
105
105
  try {
106
- config = loadServerConfig();
106
+ config = loadServerConfig({ rootDir });
107
107
  } catch (error) {
108
108
  process.stderr.write(`${errorMessage(error)}\n`);
109
109
  process.exit(1);
110
110
  }
111
111
 
112
+ process.chdir(config.repoRoot);
113
+
112
114
  const logger = createLogger(config.log);
113
115
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
114
116
  const ownStartTime = getProcessStartTime(process.pid);
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { spawn, execFileSync } from 'node:child_process';
3
4
  import { loadServerConfig } from './config.ts';
4
5
  import type { ServerConfig } from './config.ts';
@@ -91,7 +92,7 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
91
92
  }
92
93
 
93
94
  if (foreground) {
94
- await runDaemon();
95
+ await runDaemon({ rootDir: config.repoRoot });
95
96
  return;
96
97
  }
97
98
 
@@ -103,8 +104,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
103
104
  // Re-spawn ourselves detached. process.execArgv is forwarded so the dev path
104
105
  // (node --experimental-strip-types ./bin/cli.ts) and the built path
105
106
  // (node dist/bin/cli.js) both work.
106
- const child = spawn(process.execPath, [...process.execArgv, cliEntry, 'server', '__daemon'], {
107
+ const child = spawn(process.execPath, [...process.execArgv, path.resolve(cliEntry), 'server', '__daemon'], {
107
108
  detached: true,
109
+ cwd: config.repoRoot,
108
110
  stdio: 'ignore'
109
111
  });
110
112
  child.unref();
@@ -10,8 +10,9 @@ export type RunnerResult = {
10
10
  };
11
11
 
12
12
  export type RunnerOptions = {
13
- spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>) => Promise<RunnerResult>;
13
+ spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>, cwd?: string) => Promise<RunnerResult>;
14
14
  onChunk?: (chunk: string) => void | Promise<void>;
15
+ cwd?: string;
15
16
  };
16
17
 
17
18
  function resolveCommand(file: string): string {
@@ -40,13 +41,15 @@ function needsShell(file: string): boolean {
40
41
  function spawnCapture(
41
42
  file: string,
42
43
  args: string[],
43
- onChunk?: (chunk: string) => void | Promise<void>
44
+ onChunk?: (chunk: string) => void | Promise<void>,
45
+ cwd?: string
44
46
  ): Promise<RunnerResult> {
45
47
  return new Promise((resolve, reject) => {
46
48
  const resolvedFile = resolveCommand(file);
47
49
  const child = spawn(resolvedFile, args, {
48
50
  stdio: ['ignore', 'pipe', 'pipe'],
49
- shell: needsShell(resolvedFile)
51
+ shell: needsShell(resolvedFile),
52
+ ...(cwd === undefined ? {} : { cwd })
50
53
  });
51
54
  let stdout = '';
52
55
  let stderr = '';
@@ -91,5 +94,5 @@ function spawnCapture(
91
94
  }
92
95
 
93
96
  export async function runAi(args: string[], options: RunnerOptions = {}): Promise<RunnerResult> {
94
- return (options.spawn ?? spawnCapture)('ai', args, options.onChunk);
97
+ return (options.spawn ?? spawnCapture)('ai', args, options.onChunk, options.cwd);
95
98
  }
@@ -13,10 +13,6 @@
13
13
  "type": "object",
14
14
  "additionalProperties": false,
15
15
  "properties": {
16
- "path": {
17
- "type": "string",
18
- "description": "Log file path. Defaults to ~/.agent-infra/logs/<project>/<repo-hash>/server.log (outside the repo; the repo-hash isolates checkouts that share a project). Relative paths resolve against the repo root; absolute paths are used as-is."
19
- },
20
16
  "rotateAtBytes": {
21
17
  "type": "integer",
22
18
  "minimum": 0,
@@ -9,6 +9,8 @@ import { parseImplementationInputs, selectPendingImplementationInput } from './i
9
9
  import { parseVerdict } from './review-artifacts.ts';
10
10
  import { extractSection, findSectionHeading } from './sections.ts';
11
11
  import { receiptForOutput, sha256File } from './artifact-receipts.ts';
12
+ import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.ts';
13
+ import { inspectReviewIdentity } from './review-identity.ts';
12
14
  import { isArtifactInvalidated, parseInvalidationDocument } from './invalidation.ts';
13
15
  import type { InvalidationDocument } from './invalidation.ts';
14
16
  import type { ArtifactFamily, ArtifactFamilySpec } from './artifact-name.ts';
@@ -228,7 +230,12 @@ function inspectArtifactDirectory(
228
230
  if (!knownRounds.has(round)) diagnostics.push(diagnostic('ROUND_GAP', family, null, `${artifactName(family, round)} is missing`));
229
231
  }
230
232
  const latest = artifacts.at(-1) ?? null;
231
- const reviewedInput = family.startsWith('review-') && latest
233
+ let hasReviewCodeReceipt = false;
234
+ if (family === 'review-code' && latest) {
235
+ try { hasReviewCodeReceipt = Boolean(receiptForOutput(fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'), latest.name)); }
236
+ catch { hasReviewCodeReceipt = false; }
237
+ }
238
+ const reviewedInput = family.startsWith('review-') && latest && (family !== 'review-code' || hasReviewCodeReceipt)
232
239
  ? resolveReviewedInput(taskDir, latest, family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code', diagnostics)
233
240
  : null;
234
241
  return {
@@ -316,14 +323,16 @@ function assertWritableInventory(inventory: ArtifactInventoryResult): ArtifactEr
316
323
  }
317
324
 
318
325
  const REQUIRED_INPUT: Partial<Record<ArtifactFamily, ArtifactFamily>> = {
319
- 'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan', 'review-code': 'code'
326
+ 'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan'
320
327
  };
321
- const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, { family: ArtifactFamily }>> = {
322
- analysis: { family: 'review-analysis' },
323
- plan: { family: 'review-plan' },
324
- 'review-code': { family: 'review-plan' },
325
- 'manual-validation': { family: 'review-code' },
326
- 'validation-run': { family: 'review-code' }
328
+ const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, readonly ArtifactFamily[]>> = {
329
+ analysis: ['review-analysis'],
330
+ plan: ['review-analysis', 'review-plan'],
331
+ 'review-plan': ['review-analysis'],
332
+ code: ['review-plan'],
333
+ 'review-code': ['code', 'plan', 'review-plan'],
334
+ 'manual-validation': ['review-code'],
335
+ 'validation-run': ['review-code']
327
336
  };
328
337
  function hasSelectionEvidence(
329
338
  content: string,
@@ -336,6 +345,18 @@ function hasSelectionEvidence(
336
345
  if (family.startsWith('review-')) {
337
346
  const reviewedFamily = family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
338
347
  const currentInput = context.inputs.find((input) => input.family === reviewedFamily);
348
+ if (family === 'review-code') {
349
+ if (!context.latest) return false;
350
+ try {
351
+ const reportContent = fs.readFileSync(context.latest.path, 'utf8');
352
+ const identity = inspectReviewIdentity(context.taskDir!, reportContent, options.repoRoot);
353
+ return identity.status !== 'matched'
354
+ || !hasArtifactCompletionFact(content, context.latest.path, 'review-code.completed')
355
+ || !hasArtifactCompletionLog(content, context.latest.name, 'review-code.completed');
356
+ } catch {
357
+ return true;
358
+ }
359
+ }
339
360
  if (currentInput && context.reviewedInput?.name !== currentInput.name) return true;
340
361
  }
341
362
  const target = family === 'analysis' ? 'analysis' : family === 'plan' ? 'plan' : family === 'code' ? 'code' : null;
@@ -450,9 +471,9 @@ function resolveArtifactContext(taskRef: string, family: string, options: Inspec
450
471
  }
451
472
  const optional = OPTIONAL_CONTEXT[inventory.family as ArtifactFamily];
452
473
  if (optional) {
453
- const context = inspectTaskArtifacts(taskRef, optional.family, options);
454
- if (context.status === 'ready' && context.latest) {
455
- inputs.push(context.latest);
474
+ for (const family of optional) {
475
+ const context = inspectTaskArtifacts(taskRef, family, options);
476
+ if (context.status === 'ready' && context.latest) inputs.push(context.latest);
456
477
  }
457
478
  }
458
479
  if (inventory.family === 'manual-validation' || inventory.family === 'validation-run') {
@@ -473,6 +494,9 @@ function resolveCodeContext(inventory: ArtifactInventoryResult, options: Inspect
473
494
  const codeMax = latestCode?.round ?? 0;
474
495
  const reviewMax = reviewCode.latest?.round ?? 0;
475
496
  const inputs = [source.latest];
497
+ const sourceReviewFamily = inputFamily === 'analysis' ? 'review-analysis' : 'review-plan';
498
+ const sourceReview = inspectTaskArtifacts(taskRef, sourceReviewFamily, options);
499
+ if (sourceReview.status === 'ready' && sourceReview.latest && sourceReview.reviewedInput?.name === source.latest.name) inputs.push(sourceReview.latest);
476
500
  if (latestCode) {
477
501
  try {
478
502
  const taskContent = fs.readFileSync(path.join(inventory.taskDir!, 'task.md'), 'utf8');
@@ -7,24 +7,30 @@ import { extractSection, findSectionHeading, parseTable } from './sections.ts';
7
7
  const RECEIPT_SECTION_ALIASES = ['产物生命周期收据', 'Artifact Lifecycle Receipts'] as const;
8
8
  const RECEIPT_COLUMNS = ['event', 'output', 'input', 'input_sha256', 'completed_at'] as const;
9
9
  const RECEIPT_EVENTS = new Set([
10
+ 'analysis.completed',
11
+ 'plan.completed',
10
12
  'review-analysis.completed',
11
13
  'review-plan.completed',
14
+ 'code.completed',
12
15
  'review-code.completed',
13
- 'code.completed'
14
16
  ]);
15
17
  const SHA256_RE = /^[a-f0-9]{64}$/;
16
18
  const RECEIPT_SHAPES = {
19
+ 'analysis.completed': { output: 'analysis', input: 'review-analysis' },
20
+ 'plan.completed': { output: 'plan', input: 'analysis' },
17
21
  'review-analysis.completed': { output: 'review-analysis', input: 'analysis' },
18
22
  'review-plan.completed': { output: 'review-plan', input: 'plan' },
19
- 'review-code.completed': { output: 'review-code', input: 'code' },
20
- 'code.completed': { output: 'code', input: 'plan' }
23
+ 'code.completed': { output: 'code', input: 'plan' },
24
+ 'review-code.completed': { output: 'review-code', input: 'code' }
21
25
  } as const;
22
26
 
23
27
  type ArtifactReceiptEvent =
28
+ | 'analysis.completed'
29
+ | 'plan.completed'
24
30
  | 'review-analysis.completed'
25
31
  | 'review-plan.completed'
26
- | 'review-code.completed'
27
- | 'code.completed';
32
+ | 'code.completed'
33
+ | 'review-code.completed';
28
34
  type ArtifactReceipt = {
29
35
  event: ArtifactReceiptEvent;
30
36
  output: string;
@@ -65,8 +71,16 @@ function validateReceiptShape(event: ArtifactReceiptEvent, output: string, input
65
71
  if (!outputIdentity || !inputIdentity) throw new ArtifactReceiptError(`receipt artifact identity is invalid: ${output} -> ${input}`);
66
72
  const shape = RECEIPT_SHAPES[event];
67
73
  const inputMatches = event === 'code.completed'
68
- ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan'
69
- : inputIdentity.family === shape.input;
74
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan' || inputIdentity.family === 'review-code'
75
+ : event === 'plan.completed'
76
+ ? inputIdentity.family === 'analysis' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan'
77
+ : event === 'analysis.completed'
78
+ ? inputIdentity.family === 'review-analysis'
79
+ : event === 'review-plan.completed'
80
+ ? inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis'
81
+ : event === 'review-code.completed'
82
+ ? inputIdentity.family === 'code' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-plan'
83
+ : inputIdentity.family === shape.input;
70
84
  if (outputIdentity.family !== shape.output || !inputMatches) {
71
85
  throw new ArtifactReceiptError(`receipt event '${event}' does not match ${output} -> ${input}`);
72
86
  }
@@ -81,7 +95,9 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
81
95
  table = parseTable(content, {
82
96
  sectionAliases: [...RECEIPT_SECTION_ALIASES],
83
97
  columns: [...RECEIPT_COLUMNS],
84
- keyColumn: 'output'
98
+ // A completed artifact can have multiple lifecycle inputs. Pair
99
+ // uniqueness is checked below because the table parser supports one key.
100
+ keyColumn: null
85
101
  });
86
102
  } catch (error) {
87
103
  throw new ArtifactReceiptError(error instanceof Error ? error.message : String(error));
@@ -109,15 +125,31 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
109
125
  completedAt
110
126
  };
111
127
  });
128
+ const edges = new Set<string>();
129
+ for (const row of rows) {
130
+ const edge = `${row.output}\0${row.input}`;
131
+ if (edges.has(edge)) throw new ArtifactReceiptError(`duplicate receipt edge '${row.output}' <- '${row.input}'`);
132
+ edges.add(edge);
133
+ }
112
134
  return { present: true, rows };
113
135
  }
114
136
 
137
+ function receiptsForOutput(content: string, output: string): readonly ArtifactReceipt[] {
138
+ return parseArtifactReceipts(content).rows.filter((row) => row.output === output);
139
+ }
140
+
115
141
  function receiptForOutput(content: string, output: string): ArtifactReceipt | null {
116
- const parsed = parseArtifactReceipts(content);
117
- return parsed.rows.find((row) => row.output === output) ?? null;
142
+ return receiptsForOutput(content, output)[0] ?? null;
118
143
  }
119
144
 
120
145
  function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): ReceiptSectionMutation {
146
+ return upsertArtifactReceipts(content, [receipt]);
147
+ }
148
+
149
+ function upsertArtifactReceipts(content: string, receiptsToAdd: readonly ArtifactReceipt[]): ReceiptSectionMutation {
150
+ if (receiptsToAdd.length === 0) throw new ArtifactReceiptError('at least one receipt is required');
151
+ const additions = new Map<string, ArtifactReceipt>();
152
+ for (const receipt of receiptsToAdd) {
121
153
  if (!RECEIPT_EVENTS.has(receipt.event)) throw new ArtifactReceiptError(`unknown receipt event '${receipt.event}'`);
122
154
  if (!receipt.output || !receipt.input) throw new ArtifactReceiptError('receipt output and input are required');
123
155
  validateReceiptShape(receipt.event, receipt.output, receipt.input);
@@ -126,12 +158,18 @@ function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): Recei
126
158
  throw new ArtifactReceiptError(`receipt completion time for '${receipt.output}' is invalid`);
127
159
  }
128
160
 
161
+ const key = `${receipt.output}\0${receipt.input}`;
162
+ const duplicate = additions.get(key);
163
+ if (duplicate && JSON.stringify(duplicate) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' has conflicting evidence`);
164
+ additions.set(key, receipt);
165
+ }
129
166
  const existing = parseArtifactReceipts(content).rows;
130
- const previous = existing.find((row) => row.output === receipt.output);
131
- if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) {
132
- throw new ArtifactReceiptError(`receipt for '${receipt.output}' already exists with different evidence`);
167
+ for (const receipt of additions.values()) {
168
+ const previous = existing.find((row) => row.output === receipt.output && row.input === receipt.input);
169
+ if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' already exists with different evidence`);
133
170
  }
134
- const rows = previous ? existing : [...existing, receipt];
171
+ const rows = [...existing];
172
+ for (const receipt of additions.values()) if (!rows.some((row) => row.output === receipt.output && row.input === receipt.input)) rows.push(receipt);
135
173
  const heading = findSectionHeading(content, [...RECEIPT_SECTION_ALIASES]);
136
174
  const body = [
137
175
  `| ${RECEIPT_COLUMNS.join(' | ')} |`,
@@ -146,8 +184,10 @@ export {
146
184
  RECEIPT_SECTION_ALIASES,
147
185
  parseArtifactReceipts,
148
186
  receiptForOutput,
187
+ receiptsForOutput,
149
188
  sha256Bytes,
150
189
  sha256File,
151
- upsertArtifactReceipt
190
+ upsertArtifactReceipt,
191
+ upsertArtifactReceipts
152
192
  };
153
193
  export type { ArtifactReceipt, ArtifactReceiptEvent, ArtifactReceiptParseResult, ReceiptSectionMutation };