@fitlab-ai/agent-infra 0.11.12 → 0.11.13

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 (68) hide show
  1. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
  2. package/dist/lib/internal/sandbox-control.js +1 -81
  3. package/dist/lib/internal/task-finalization.js +26 -19
  4. package/dist/lib/internal/task-lifecycle.js +1 -1
  5. package/dist/lib/platform/github-client.js +33 -3
  6. package/dist/lib/sandbox/commands/create.js +6 -2
  7. package/dist/lib/sandbox/commands/ls.js +4 -5
  8. package/dist/lib/sandbox/commands/rm.js +1 -1
  9. package/dist/lib/sandbox/control/client.js +4 -64
  10. package/dist/lib/sandbox/control/executor.js +4 -50
  11. package/dist/lib/sandbox/control/lifecycle.js +66 -4
  12. package/dist/lib/sandbox/control/protocol.js +5 -16
  13. package/dist/lib/sandbox/control/server.js +203 -234
  14. package/dist/lib/sandbox/control/task-view.js +0 -13
  15. package/dist/lib/sandbox/mounts.js +7 -0
  16. package/dist/lib/sandbox/recovery.js +28 -9
  17. package/dist/lib/sandbox/removal.js +19 -0
  18. package/dist/lib/sandbox/workspace-identity.js +18 -6
  19. package/dist/lib/sandbox/workspace-view.js +48 -2
  20. package/dist/lib/task/commands/ls.js +3 -3
  21. package/dist/lib/task/control-authority.js +2 -0
  22. package/dist/lib/task/control-recovery.js +1 -2
  23. package/dist/lib/task/finalization.js +14 -0
  24. package/dist/lib/task/lifecycle.js +203 -31
  25. package/dist/lib/task/orchestration.js +26 -72
  26. package/dist/lib/task/short-id.js +37 -27
  27. package/dist/lib/task/verification-engine.js +4 -11
  28. package/dist/lifecycle-build-manifest.json +3 -3
  29. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +20 -23
  30. package/lib/internal/sandbox-control.ts +0 -97
  31. package/lib/internal/task-finalization.ts +30 -18
  32. package/lib/internal/task-lifecycle.ts +1 -1
  33. package/lib/platform/github-client.ts +34 -3
  34. package/lib/sandbox/commands/create.ts +6 -1
  35. package/lib/sandbox/commands/ls.ts +4 -5
  36. package/lib/sandbox/commands/rm.ts +1 -1
  37. package/lib/sandbox/control/client.ts +5 -78
  38. package/lib/sandbox/control/executor.ts +2 -53
  39. package/lib/sandbox/control/lifecycle.ts +67 -4
  40. package/lib/sandbox/control/protocol.ts +6 -20
  41. package/lib/sandbox/control/server.ts +9 -44
  42. package/lib/sandbox/control/task-view.ts +0 -19
  43. package/lib/sandbox/mounts.ts +8 -0
  44. package/lib/sandbox/recovery.ts +35 -16
  45. package/lib/sandbox/removal.ts +21 -2
  46. package/lib/sandbox/workspace-identity.ts +16 -6
  47. package/lib/sandbox/workspace-view.ts +41 -2
  48. package/lib/task/commands/ls.ts +3 -3
  49. package/lib/task/control-authority.ts +1 -0
  50. package/lib/task/control-recovery.ts +2 -3
  51. package/lib/task/finalization.ts +14 -0
  52. package/lib/task/lifecycle.ts +180 -27
  53. package/lib/task/orchestration.ts +30 -68
  54. package/lib/task/short-id.ts +33 -40
  55. package/lib/task/verification-engine.ts +3 -9
  56. package/package.json +1 -1
  57. package/templates/.agents/rules/README.en.md +1 -0
  58. package/templates/.agents/rules/README.zh-CN.md +1 -0
  59. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  60. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  61. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  62. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  63. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
  64. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
  65. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  66. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  67. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  68. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
@@ -18,6 +18,7 @@ const lifecycleIntentCatalog = [
18
18
  'block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'
19
19
  ];
20
20
  const lifecycleFailureCatalog = [
21
+ 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED',
21
22
  'LIFECYCLE_DIRECTORY_RENAME_FAILED',
22
23
  'LIFECYCLE_DOCUMENT_INVALID',
23
24
  'LIFECYCLE_FINAL_STATE_INVALID',
@@ -46,6 +47,9 @@ const lifecycleProducerCatalog = lifecycleFailureCatalog.map((code) => ({
46
47
  code
47
48
  }));
48
49
  const STEPS = ['task-written', 'directory-moved', 'registry-committed'];
50
+ function stepsForIntent(intent) {
51
+ return intent === 'complete' ? ['task-written', 'directory-moved'] : STEPS;
52
+ }
49
53
  const DEFAULT_IO = {
50
54
  renameSync: (source, target) => fs.renameSync(source, target),
51
55
  readFileSync: (file) => fs.readFileSync(file, 'utf8'),
@@ -58,7 +62,7 @@ function failed(request, error, extra = {}) {
58
62
  intent: request.intent, sourceState: null, targetState: null, sourcePath: null,
59
63
  targetPath: null, task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
60
64
  shortId: { effect: 'unchanged', shortId: null, changed: false }, timestamp: null,
61
- agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: STEPS,
65
+ agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: stepsForIntent(request.intent),
62
66
  error, ...extra
63
67
  };
64
68
  }
@@ -134,8 +138,105 @@ function transition(request) {
134
138
  return { sources: ['active', 'blocked'], target: 'completed', registry: 'release' };
135
139
  if (request.intent === 'restore')
136
140
  return { sources: ['staging'], target: 'active', registry: 'alloc' };
141
+ if (request.intent === 'complete')
142
+ return { sources: ['active'], target: 'completed', registry: 'none' };
137
143
  return { sources: ['active'], target: 'completed', registry: 'release' };
138
144
  }
145
+ function sameTaskEntry(source, target) {
146
+ const sourceStat = fs.lstatSync(source);
147
+ let targetStat;
148
+ try {
149
+ targetStat = fs.lstatSync(target);
150
+ }
151
+ catch (error) {
152
+ if (error.code === 'ENOENT')
153
+ return false;
154
+ throw error;
155
+ }
156
+ if (sourceStat.isSymbolicLink() || targetStat.isSymbolicLink()) {
157
+ return sourceStat.isSymbolicLink() && targetStat.isSymbolicLink()
158
+ && fs.readlinkSync(source) === fs.readlinkSync(target);
159
+ }
160
+ if (sourceStat.isDirectory() && targetStat.isDirectory()) {
161
+ const sourceNames = fs.readdirSync(source).sort();
162
+ const targetNames = fs.readdirSync(target).sort();
163
+ return sourceNames.join('\0') === targetNames.join('\0')
164
+ && sourceNames.every((name) => sameTaskEntry(path.join(source, name), path.join(target, name)))
165
+ && (sourceStat.mode & 0o777) === (targetStat.mode & 0o777);
166
+ }
167
+ return sourceStat.isFile() && targetStat.isFile()
168
+ && (sourceStat.mode & 0o777) === (targetStat.mode & 0o777)
169
+ && fs.readFileSync(source).equals(fs.readFileSync(target));
170
+ }
171
+ function copyTaskEntry(source, target) {
172
+ const sourceStat = fs.lstatSync(source);
173
+ if (fs.existsSync(target)) {
174
+ const targetStat = fs.lstatSync(target);
175
+ if (sourceStat.isDirectory() && targetStat.isDirectory() && !targetStat.isSymbolicLink()) {
176
+ for (const name of fs.readdirSync(source).sort())
177
+ copyTaskEntry(path.join(source, name), path.join(target, name));
178
+ fs.chmodSync(target, sourceStat.mode & 0o777);
179
+ return;
180
+ }
181
+ if (sameTaskEntry(source, target))
182
+ return;
183
+ throw new Error(`task content conflict at ${path.relative(path.dirname(source), source)}`);
184
+ }
185
+ if (sourceStat.isSymbolicLink()) {
186
+ fs.symlinkSync(fs.readlinkSync(source), target);
187
+ return;
188
+ }
189
+ if (sourceStat.isDirectory()) {
190
+ fs.mkdirSync(target, { mode: sourceStat.mode & 0o777 });
191
+ for (const name of fs.readdirSync(source).sort())
192
+ copyTaskEntry(path.join(source, name), path.join(target, name));
193
+ fs.chmodSync(target, sourceStat.mode & 0o777);
194
+ return;
195
+ }
196
+ if (!sourceStat.isFile())
197
+ throw new Error(`unsupported task entry: ${source}`);
198
+ fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
199
+ fs.chmodSync(target, sourceStat.mode & 0o777);
200
+ }
201
+ function transferTaskContents(sourcePath, targetPath) {
202
+ const sourceRoot = fs.lstatSync(sourcePath);
203
+ if (!sourceRoot.isDirectory() || sourceRoot.isSymbolicLink())
204
+ throw new Error('task source is not a real directory');
205
+ const targetRoot = fs.lstatSync(targetPath);
206
+ if (!targetRoot.isDirectory() || targetRoot.isSymbolicLink())
207
+ throw new Error('completed target is not a real directory');
208
+ for (const name of fs.readdirSync(sourcePath).sort()) {
209
+ copyTaskEntry(path.join(sourcePath, name), path.join(targetPath, name));
210
+ }
211
+ const sourceNames = fs.readdirSync(sourcePath).sort();
212
+ const targetNames = fs.readdirSync(targetPath).sort();
213
+ if (sourceNames.join('\0') !== targetNames.join('\0')
214
+ || !sourceNames.every((name) => sameTaskEntry(path.join(sourcePath, name), path.join(targetPath, name)))) {
215
+ throw new Error('completed target does not match task source after copy');
216
+ }
217
+ }
218
+ function targetContainsTask(targetPath, taskId, request) {
219
+ try {
220
+ const stat = fs.lstatSync(targetPath);
221
+ if (!stat.isDirectory() || stat.isSymbolicLink())
222
+ return false;
223
+ const entries = fs.readdirSync(targetPath);
224
+ if (entries.length === 0)
225
+ return true;
226
+ const taskPath = path.join(targetPath, 'task.md');
227
+ const journalPath = path.join(targetPath, '.task-lifecycle.json');
228
+ if (!fs.existsSync(taskPath) || !fs.existsSync(journalPath))
229
+ return false;
230
+ const frontmatter = parseTypedTaskFrontmatter(fs.readFileSync(taskPath, 'utf8'));
231
+ const journal = parseJournal(fs.readFileSync(journalPath, 'utf8'));
232
+ return frontmatter.id === taskId && frontmatter.status === 'completed'
233
+ && journal.taskId === taskId && journal.intent === request.intent
234
+ && journal.intentDigest === intentIdentity(request, taskId);
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
139
240
  function actionAndNote(request, restoredFiles = 0) {
140
241
  if (request.intent === 'block')
141
242
  return { action: 'Block Task', note: request.reason };
@@ -367,7 +468,7 @@ export function inspectTaskLifecycleProgress(repoRoot, taskId, agent) {
367
468
  || journal.intentDigest !== intentIdentity(request, taskId)
368
469
  || journal.sourceState !== 'active' || journal.targetState !== 'completed')
369
470
  return 'unknown';
370
- if (journal.completedSteps.some((step, index) => STEPS[index] !== step))
471
+ if (journal.completedSteps.some((step, index) => stepsForIntent(journal.intent)[index] !== step))
371
472
  return 'unknown';
372
473
  const directoryMoved = journal.completedSteps.includes('directory-moved');
373
474
  const registryCommitted = journal.completedSteps.includes('registry-committed');
@@ -375,7 +476,7 @@ export function inspectTaskLifecycleProgress(repoRoot, taskId, agent) {
375
476
  if (state === 'active' && (directoryMoved || !hasShortId))
376
477
  return 'unknown';
377
478
  if (state === 'completed' && (!journal.completedSteps.includes('task-written')
378
- || !matchingCompletion(content, request) || (registryCommitted && hasShortId)))
479
+ || !matchingCompletion(content, request) || registryCommitted || !hasShortId))
379
480
  return 'unknown';
380
481
  if (frontmatter.status !== state
381
482
  && !(state === 'active' && frontmatter.status === 'completed' && matchingCompletion(content, request))) {
@@ -411,7 +512,12 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
411
512
  }
412
513
  const spec = transition(request);
413
514
  const hot = locateHotTaskDirs(repoRoot, taskId);
414
- if (hot.length > 1)
515
+ const activeSource = hot.find((candidate) => candidate.state === 'active');
516
+ const completedTarget = hot.find((candidate) => candidate.state === 'completed');
517
+ const transferInProgress = request.intent === 'complete' && Boolean(activeSource && completedTarget)
518
+ && targetContainsTask(completedTarget.taskDir, taskId, request)
519
+ && fs.existsSync(path.join(activeSource.taskDir, '.task-lifecycle.json'));
520
+ if (hot.length > 1 && !transferInProgress)
415
521
  return failed(request, { code: 'LIFECYCLE_IDENTITY_CONFLICT', message: `task ${taskId} exists in multiple hot states` }, { taskId });
416
522
  let sourceState;
417
523
  let sourcePath;
@@ -424,6 +530,11 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
424
530
  sourcePath = validation.stagingDir;
425
531
  restoredFiles = validation.fileCount;
426
532
  }
533
+ else if (activeSource && request.intent === 'complete') {
534
+ sourceState = activeSource.state;
535
+ sourcePath = activeSource.taskDir;
536
+ restoredFiles = fs.readdirSync(sourcePath).filter((name) => !name.startsWith('.')).length;
537
+ }
427
538
  else if (hot[0]) {
428
539
  sourceState = hot[0].state;
429
540
  sourcePath = hot[0].taskDir;
@@ -433,13 +544,14 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
433
544
  return failed(request, { code: 'LIFECYCLE_TASK_NOT_FOUND', message: `task ${taskId} has no lifecycle source` }, { taskId });
434
545
  }
435
546
  const targetPath = path.join(repoRoot, '.agents', 'workspace', spec.target, taskId);
547
+ const requiredSteps = stepsForIntent(request.intent);
436
548
  const movingTaskDirectory = sourcePath !== targetPath;
437
549
  const assertRuntimeMoveSafe = () => {
438
550
  if (!movingTaskDirectory)
439
551
  return null;
440
552
  const controlManifest = path.join(sourcePath, '.runtime', 'sandbox-control', 'manifest.json');
441
553
  const controlLease = path.join(sourcePath, '.runtime', 'sandbox-control', 'lease.json');
442
- if (fs.existsSync(controlManifest) || fs.existsSync(controlLease)) {
554
+ if (request.intent !== 'complete' && (fs.existsSync(controlManifest) || fs.existsSync(controlLease))) {
443
555
  return {
444
556
  code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
445
557
  message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory'
@@ -474,7 +586,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
474
586
  return {
475
587
  ...failed(request, { code: '', message: '' }), status: 'no-op', error: null, taskId,
476
588
  sourceState, targetState: spec.target, sourcePath, targetPath, pendingSteps: [],
477
- completedSteps: STEPS, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
589
+ completedSteps: requiredSteps, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
478
590
  agentInfraVersion: typeof frontmatter.agent_infra_version === 'string' ? frontmatter.agent_infra_version : null
479
591
  };
480
592
  }
@@ -483,7 +595,8 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
483
595
  if (!spec.sources.includes(sourceState) && !existingJournal) {
484
596
  return failed(request, { code: 'LIFECYCLE_SOURCE_INVALID', message: `${request.intent} is not allowed from ${sourceState}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
485
597
  }
486
- if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal) {
598
+ if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal
599
+ && !(request.intent === 'complete' && targetContainsTask(targetPath, taskId, request))) {
487
600
  return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
488
601
  }
489
602
  const moveSafety = assertRuntimeMoveSafe();
@@ -515,7 +628,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
515
628
  }
516
629
  const registry = JSON.parse(registryRequest.output || '{"version":1,"ids":{}}');
517
630
  const registeredKey = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId)?.[0];
518
- if (spec.registry === 'release' && sourceState === 'active' && !registeredKey) {
631
+ if ((spec.registry === 'release' || request.intent === 'complete') && sourceState === 'active' && !registeredKey) {
519
632
  return failed(request, { code: 'LIFECYCLE_SHORT_ID_PRECONDITION', message: 'active source has no short-id registry entry' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
520
633
  }
521
634
  if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1) {
@@ -540,7 +653,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
540
653
  if (request.dryRun) {
541
654
  if (!journal.failure)
542
655
  return failed(request, { code: 'LIFECYCLE_JOURNAL_INVALID', message: 'existing lifecycle journal has no durable failure evidence for a read-only probe' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath });
543
- return failed(request, journal.failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath, completedSteps: journal.completedSteps, pendingSteps: STEPS.filter((step) => !journal.completedSteps.includes(step)) });
656
+ return failed(request, journal.failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath, completedSteps: journal.completedSteps, pendingSteps: requiredSteps.filter((step) => !journal.completedSteps.includes(step)) });
544
657
  }
545
658
  if (journal.intentDigest !== digest)
546
659
  return failed(request, { code: 'LIFECYCLE_INTENT_CONFLICT', message: 'an in-progress lifecycle journal belongs to a different request' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath });
@@ -591,7 +704,8 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
591
704
  if (sourceState === spec.target && journal.sourceState !== spec.target)
592
705
  completed.add('directory-moved');
593
706
  const currentMappings = loadShortIdByTaskId(repoRoot);
594
- if ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId))) {
707
+ if (requiredSteps.includes('registry-committed')
708
+ && ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId)))) {
595
709
  if (completed.has('directory-moved'))
596
710
  completed.add('registry-committed');
597
711
  }
@@ -603,7 +717,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
603
717
  task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
604
718
  shortId: { effect: 'unchanged', shortId: null, changed: false },
605
719
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
606
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
720
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
607
721
  };
608
722
  }
609
723
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles))
@@ -637,30 +751,42 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
637
751
  task: { operations: taskOperations }, directory: { effect: 'unchanged', changed: false },
638
752
  shortId: { effect: 'unchanged', shortId: null, changed: false },
639
753
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
640
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
754
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
641
755
  };
642
756
  }
643
- if (fs.existsSync(targetPath) && !fs.existsSync(sourcePath))
757
+ if (fs.existsSync(targetPath) && !fs.existsSync(path.join(sourcePath, 'task.md')))
644
758
  completed.add('directory-moved');
645
759
  if (!completed.has('directory-moved')) {
646
760
  const moveSafety = assertRuntimeMoveSafe();
647
761
  if (moveSafety)
648
762
  return failed(request, moveSafety, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, completedSteps: [...completed], changed: completed.size > 0, timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
649
763
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
650
- if (fs.existsSync(targetPath))
651
- return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
652
- try {
653
- (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath);
654
- }
655
- catch (error) {
656
- const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) };
657
- rememberJournalFailure(journalPath, journal, io, failure);
658
- return failed(request, failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
659
- }
660
- if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
661
- journalPath = path.join(targetPath, '.task-lifecycle.json');
662
- }
663
- else if (!completed.has('directory-moved')) {
764
+ if (request.intent === 'complete') {
765
+ try {
766
+ if (!fs.existsSync(targetPath))
767
+ fs.mkdirSync(targetPath, { recursive: false, mode: 0o700 });
768
+ if (!targetContainsTask(targetPath, taskId, request)) {
769
+ throw new Error('completed target has no matching task identity');
770
+ }
771
+ transferTaskContents(sourcePath, targetPath);
772
+ for (const name of fs.readdirSync(sourcePath).filter((entry) => entry !== '.task-lifecycle.json' && entry !== 'task.md').sort()) {
773
+ fs.rmSync(path.join(sourcePath, name), { recursive: true, force: false });
774
+ }
775
+ fs.rmSync(path.join(sourcePath, 'task.md'), { force: false });
776
+ fs.rmSync(path.join(sourcePath, '.task-lifecycle.json'), { force: false });
777
+ }
778
+ catch (error) {
779
+ const failure = { code: 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED', message: String(error) };
780
+ const sourceJournalPath = path.join(sourcePath, '.task-lifecycle.json');
781
+ rememberJournalFailure(sourceJournalPath, journal, io, failure);
782
+ return failed(request, failure, {
783
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath,
784
+ journalPath: sourceJournalPath, task: { operations: taskOperations },
785
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
786
+ pendingSteps: requiredSteps.filter((step) => !completed.has(step) || step === 'directory-moved'),
787
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
788
+ });
789
+ }
664
790
  completed.add('directory-moved');
665
791
  journal.completedSteps = [...completed];
666
792
  journalPath = path.join(targetPath, '.task-lifecycle.json');
@@ -668,12 +794,58 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
668
794
  writeJournal(journalPath, journal, io);
669
795
  }
670
796
  catch (error) {
671
- return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, directory: { effect: 'move', changed: true }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
797
+ return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, {
798
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath,
799
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
800
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
801
+ pendingSteps: requiredSteps.filter((step) => step !== 'task-written'),
802
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
803
+ });
804
+ }
805
+ }
806
+ else {
807
+ if (fs.existsSync(targetPath))
808
+ return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
809
+ try {
810
+ (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath);
811
+ }
812
+ catch (error) {
813
+ const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) };
814
+ rememberJournalFailure(journalPath, journal, io, failure);
815
+ return failed(request, failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
816
+ }
817
+ if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
818
+ journalPath = path.join(targetPath, '.task-lifecycle.json');
819
+ }
820
+ else if (!completed.has('directory-moved')) {
821
+ completed.add('directory-moved');
822
+ journal.completedSteps = [...completed];
823
+ journalPath = path.join(targetPath, '.task-lifecycle.json');
824
+ try {
825
+ writeJournal(journalPath, journal, io);
826
+ }
827
+ catch (error) {
828
+ return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, directory: { effect: 'move', changed: true }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
829
+ }
672
830
  }
673
831
  }
674
832
  }
675
- let shortId = { effect: 'unchanged', shortId: null, changed: false };
676
- if (!completed.has('registry-committed')) {
833
+ const activeTaskPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
834
+ if (request.intent === 'complete' && fs.existsSync(activeTaskPath)) {
835
+ try {
836
+ fs.rmdirSync(activeTaskPath);
837
+ }
838
+ catch (error) {
839
+ return failed(request, { code: 'LIFECYCLE_SOURCE_DIRECTORY_CLEANUP_FAILED', message: String(error) }, {
840
+ taskId, sourceState, targetState: spec.target, sourcePath: activeTaskPath, targetPath, journalPath,
841
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
842
+ changed: true, completedSteps: [...completed], pendingSteps: [],
843
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
844
+ });
845
+ }
846
+ }
847
+ let shortId = { effect: 'unchanged', shortId: request.intent === 'complete' ? registeredKey ?? null : null, changed: false };
848
+ if (requiredSteps.includes('registry-committed') && !completed.has('registry-committed')) {
677
849
  try {
678
850
  shortId = mutateShortIdRegistry(repoRoot, taskId, spec.registry);
679
851
  }
@@ -692,7 +864,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
692
864
  const mappings = loadShortIdByTaskId(repoRoot);
693
865
  if (spec.target === 'active' && !mappings.has(taskId))
694
866
  return failed(request, { code: 'LIFECYCLE_FINAL_STATE_INVALID', message: 'active lifecycle target has no short id' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, changed: true, completedSteps: [...completed], pendingSteps: [], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
695
- if (spec.target !== 'active' && mappings.has(taskId))
867
+ if (request.intent !== 'complete' && spec.target !== 'active' && mappings.has(taskId))
696
868
  return failed(request, { code: 'LIFECYCLE_FINAL_STATE_INVALID', message: 'non-active lifecycle target retained a short id' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, changed: true, completedSteps: [...completed], pendingSteps: [], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
697
869
  try {
698
870
  io.unlinkSync(journalPath);
@@ -6,7 +6,7 @@ import { parseTypedTaskFrontmatter } from './frontmatter.js';
6
6
  import { validateCurrentTaskContract } from './current-contract.js';
7
7
  import { parseReviewSummary } from './review-artifacts.js';
8
8
  import { LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.js';
9
- import { resolveTaskRef } from './resolve-ref.js';
9
+ import { resolveTaskRef, TASK_ID_RE } from './resolve-ref.js';
10
10
  import { activateDelegation, abortActivatedDelegation, abortPreparedDelegation, completeDelegationStage, consumeDelegation, dispatchDelegation, foldBlankToNull, isDelegationReceipt, managedDelegationRole, prepareDelegation, sealDelegation } from './delegation-receipts.js';
11
11
  import { normalizeAgentClients } from '../agent-clients/config.js';
12
12
  import { getAgentClientAdapter, getAgentClientCapability, getAgentClientDelegationEvidence, getAgentClientModelSelection } from '../agent-clients/registry.js';
@@ -77,7 +77,7 @@ function isModelPolicy(value) {
77
77
  }
78
78
  const RECOVERY_GUARD_KEYS = [
79
79
  'stepCount', 'nextStage', 'baselineEmpty', 'receiptCount', 'pendingDelegation',
80
- 'commitAuthorizationUnused', 'completionEvidenceAbsent'
80
+ 'completionEvidenceAbsent'
81
81
  ];
82
82
  function isRecovery(value) {
83
83
  if (!hasExactKeys(value, [
@@ -97,7 +97,6 @@ function isRecovery(value) {
97
97
  && guards.baselineEmpty === true
98
98
  && guards.receiptCount === 0
99
99
  && guards.pendingDelegation === false
100
- && guards.commitAuthorizationUnused === true
101
100
  && guards.completionEvidenceAbsent === true
102
101
  && value.resultingStatus === 'running';
103
102
  }
@@ -117,7 +116,7 @@ export function isCompletionEvidence(value) {
117
116
  const ORCHESTRATION_RUN_KEYS = [
118
117
  'taskId', 'runId', 'status', 'nextStage', 'stepCount', 'maxSteps', 'modelPolicy',
119
118
  'modelPolicySource', 'recoveryHistory', 'baseline', 'pendingDelegation', 'receipts',
120
- 'pause', 'commitAuthorization', 'completionEvidence', 'createdAt', 'updatedAt'
119
+ 'pause', 'completionEvidence', 'createdAt', 'updatedAt'
121
120
  ];
122
121
  function orchestrationStateReasonCodes(value) {
123
122
  if (!hasExactKeys(value, ORCHESTRATION_RUN_KEYS))
@@ -151,15 +150,6 @@ function orchestrationStateReasonCodes(value) {
151
150
  reasons.push('receipts');
152
151
  if (!(record.pause === null || isPause(record.pause)))
153
152
  reasons.push('pause');
154
- if (!hasExactKeys(record.commitAuthorization, ['issuedAt', 'consumedAt'])) {
155
- reasons.push('commitAuthorization');
156
- }
157
- else {
158
- if (!nullableText(record.commitAuthorization.issuedAt))
159
- reasons.push('commitAuthorization.issuedAt');
160
- if (!nullableText(record.commitAuthorization.consumedAt))
161
- reasons.push('commitAuthorization.consumedAt');
162
- }
163
153
  if (!(record.completionEvidence === null || isCompletionEvidence(record.completionEvidence)))
164
154
  reasons.push('completionEvidence');
165
155
  if (!exactText(record.createdAt))
@@ -392,8 +382,6 @@ function canRecoverUnsupportedClientPause(run) {
392
382
  && run.baseline === ''
393
383
  && run.pendingDelegation === null
394
384
  && run.receipts.length === 0
395
- && run.commitAuthorization?.issuedAt === null
396
- && run.commitAuthorization?.consumedAt === null
397
385
  && run.completionEvidence == null
398
386
  && run.recoveryHistory.length === 0;
399
387
  }
@@ -443,7 +431,6 @@ function beginOrResumeOrchestration(taskRef, options = {}) {
443
431
  baselineEmpty: true,
444
432
  receiptCount: 0,
445
433
  pendingDelegation: false,
446
- commitAuthorizationUnused: true,
447
434
  completionEvidenceAbsent: true
448
435
  },
449
436
  resultingStatus: 'running'
@@ -497,7 +484,6 @@ function beginOrResumeOrchestration(taskRef, options = {}) {
497
484
  pendingDelegation: null,
498
485
  receipts: [],
499
486
  pause: null,
500
- commitAuthorization: { issuedAt: null, consumedAt: null },
501
487
  completionEvidence: null,
502
488
  createdAt: now,
503
489
  updatedAt: now
@@ -858,72 +844,40 @@ function dispatchOrchestrationDelegation(taskRef, options = {}) {
858
844
  }
859
845
  }
860
846
  function matchingDelegations(predicate, options) {
847
+ const taskId = options.taskId;
848
+ if (taskId === undefined || taskId === '') {
849
+ return { ok: false, error: failed('ORCHESTRATION_TASK_ID_REQUIRED', 'taskId is required for lifecycle matching') };
850
+ }
851
+ if (typeof taskId !== 'string' || !TASK_ID_RE.test(taskId)) {
852
+ return { ok: false, error: failed('ORCHESTRATION_TASK_ID_INVALID', 'taskId must be a canonical TASK-id') };
853
+ }
861
854
  const repoRoot = options.repoRoot ?? process.cwd();
862
- if (options.taskId) {
863
- const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', options.taskId);
864
- const run = readRun(taskDir, options);
865
- return run && run.status === 'running' && run.pendingDelegation && predicate(run.pendingDelegation)
866
- ? [{ taskId: options.taskId, run }]
867
- : [];
868
- }
869
- const activeRoot = path.join(repoRoot, '.agents', 'workspace', 'active');
870
- if (!fs.existsSync(activeRoot))
871
- return [];
872
- emitDiagnostic(options, 'orchestration-active-scan-start', {
873
- activeRoot
874
- });
875
- const matches = fs.readdirSync(activeRoot, { withFileTypes: true })
876
- .filter((entry) => entry.isDirectory())
877
- .flatMap((entry) => {
878
- const candidateTaskId = entry.name;
879
- const candidateTaskDir = path.join(activeRoot, candidateTaskId);
880
- emitDiagnostic(options, 'orchestration-active-scan-candidate', {
881
- candidateTaskId,
882
- candidateTaskDir,
883
- orchestrationPath: orchestrationPath(candidateTaskDir)
884
- });
885
- let run;
886
- try {
887
- run = readRun(candidateTaskDir, options);
888
- }
889
- catch (error) {
890
- emitDiagnostic(options, 'orchestration-active-scan-candidate-failed', {
891
- candidateTaskId,
892
- candidateTaskDir,
893
- errorType: error instanceof Error ? error.name : typeof error,
894
- reasonCodes: error instanceof OrchestrationStateError ? error.reasonCodes.join(',') : null
895
- });
896
- throw error;
897
- }
898
- if (!run || run.status !== 'running' || !run.pendingDelegation)
899
- return [];
900
- const receipt = run.pendingDelegation;
901
- return predicate(receipt)
902
- ? [{ taskId: candidateTaskId, run }]
903
- : [];
904
- });
905
- emitDiagnostic(options, 'orchestration-active-scan-finished', {
906
- activeRoot,
907
- matchingDelegations: matches.length
908
- });
909
- return matches;
855
+ const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
856
+ const run = readRun(taskDir, options);
857
+ const matches = run && run.status === 'running' && run.pendingDelegation && predicate(run.pendingDelegation)
858
+ ? [{ taskId, run }]
859
+ : [];
860
+ return { ok: true, matches };
910
861
  }
911
862
  function uniqueMatchingDelegation(client, predicate, options) {
912
- const matches = matchingDelegations((receipt) => receipt.client === client && predicate(receipt), options);
863
+ const lookup = matchingDelegations((receipt) => receipt.client === client && predicate(receipt), options);
864
+ if (!lookup.ok)
865
+ return lookup.error;
866
+ const matches = lookup.matches;
913
867
  if (matches.length === 0)
914
868
  return failed('ORCHESTRATION_DELEGATION_MISSING', 'no matching lifecycle delegation exists');
915
- if (matches.length > 1)
916
- return failed('ORCHESTRATION_DELEGATION_AMBIGUOUS', 'multiple lifecycle delegations match the native hook event');
917
869
  return matches[0];
918
870
  }
919
871
  function hasActivatableOrchestrationDelegation(client, childId, options = {}) {
920
- return matchingDelegations((receipt) => receipt.client === client && (receipt.status === 'prepared'
921
- || (receipt.childId === childId && ['activated', 'stage-completed'].includes(receipt.status))), options).length > 0;
872
+ const lookup = matchingDelegations((receipt) => receipt.client === client && (receipt.status === 'prepared'
873
+ || (receipt.childId === childId && ['activated', 'stage-completed'].includes(receipt.status))), options);
874
+ return lookup.ok && lookup.matches.length > 0;
922
875
  }
923
876
  function hasSealableOrchestrationDelegation(client, childId, options = {}) {
924
- return matchingDelegations((receipt) => receipt.client === client
877
+ const lookup = matchingDelegations((receipt) => receipt.client === client
925
878
  && receipt.childId === childId
926
- && ['stage-completed', 'sealed'].includes(receipt.status), options).length > 0;
879
+ && ['stage-completed', 'sealed'].includes(receipt.status), options);
880
+ return lookup.ok && lookup.matches.length > 0;
927
881
  }
928
882
  function activateMatchingOrchestrationDelegation(client, event, options = {}) {
929
883
  const role = managedDelegationRole(event.nativeAgent);