@fitlab-ai/agent-infra 0.11.11 → 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 (154) hide show
  1. package/dist/bin/internal-cli.js +2 -2
  2. package/dist/lib/agent-clients/adapter.js +7 -1
  3. package/dist/lib/agent-clients/adapters/claude-code.js +5 -1
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +78 -63
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/binding.js +114 -0
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/cli.js +447 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +130 -0
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +131 -0
  9. package/dist/lib/{sandbox/control → agent-clients/adapters/codex-lifecycle}/controller-registration.js +2 -2
  10. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +48 -1
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +124 -62
  12. package/dist/lib/{internal/codex-sandbox-controller.js → agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js} +3 -3
  13. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +3 -5
  14. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +211 -213
  15. package/dist/lib/agent-clients/adapters/codex-orchestration.js +468 -0
  16. package/dist/lib/agent-clients/adapters/codex.js +40 -1
  17. package/dist/lib/agent-clients/adapters/index.js +2 -10
  18. package/dist/lib/agent-clients/tokens.js +3 -2
  19. package/dist/lib/internal/agent-client.js +132 -72
  20. package/dist/lib/internal/cli-route-inventory.js +2 -2
  21. package/dist/lib/internal/sandbox-control.js +1 -81
  22. package/dist/lib/internal/task-finalization.js +26 -19
  23. package/dist/lib/internal/task-lifecycle.js +1 -1
  24. package/dist/lib/internal/task-operation-registry.js +1 -1
  25. package/dist/lib/internal/task-validate.js +1 -1
  26. package/dist/lib/platform/github-client.js +33 -3
  27. package/dist/lib/platform/issues.js +18 -6
  28. package/dist/lib/runtime/agent-runtime.js +6 -29
  29. package/dist/lib/sandbox/commands/control-status.js +1 -0
  30. package/dist/lib/sandbox/commands/create.js +7 -2
  31. package/dist/lib/sandbox/commands/ls.js +4 -5
  32. package/dist/lib/sandbox/commands/rm.js +1 -1
  33. package/dist/lib/sandbox/control/agent-operation.js +34 -0
  34. package/dist/lib/sandbox/control/client.js +14 -201
  35. package/dist/lib/sandbox/control/executor.js +12 -125
  36. package/dist/lib/sandbox/control/lifecycle.js +66 -4
  37. package/dist/lib/sandbox/control/protocol.js +15 -54
  38. package/dist/lib/sandbox/control/server.js +216 -281
  39. package/dist/lib/sandbox/control/task-view.js +0 -13
  40. package/dist/lib/sandbox/mounts.js +9 -1
  41. package/dist/lib/sandbox/recovery.js +31 -10
  42. package/dist/lib/sandbox/removal.js +28 -5
  43. package/dist/lib/sandbox/workspace-identity.js +18 -6
  44. package/dist/lib/sandbox/workspace-view.js +56 -19
  45. package/dist/lib/task/commands/ls.js +3 -3
  46. package/dist/lib/task/control-authority.js +56 -264
  47. package/dist/lib/task/control-recovery.js +2 -3
  48. package/dist/lib/task/create.js +2 -2
  49. package/dist/lib/task/delegation-receipts.js +74 -161
  50. package/dist/lib/task/events.js +88 -44
  51. package/dist/lib/task/finalization.js +35 -6
  52. package/dist/lib/task/lifecycle.js +274 -115
  53. package/dist/lib/task/orchestration.js +201 -194
  54. package/dist/lib/task/runtime-paths.js +10 -0
  55. package/dist/lib/task/short-id.js +37 -27
  56. package/dist/lib/task/verification-engine.js +32 -26
  57. package/dist/lib/task/workspace-snapshot.js +1 -1
  58. package/dist/lifecycle-build-manifest.json +3 -3
  59. package/lib/agent-clients/adapter.ts +39 -0
  60. package/lib/agent-clients/adapters/claude-code.ts +5 -1
  61. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +105 -80
  62. package/lib/agent-clients/adapters/codex-lifecycle/binding.ts +147 -0
  63. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +510 -0
  64. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +158 -0
  65. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +1 -1
  66. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +151 -0
  67. package/lib/{sandbox/control → agent-clients/adapters/codex-lifecycle}/controller-registration.ts +4 -4
  68. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +43 -0
  69. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +8 -5
  70. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +190 -65
  71. package/lib/{internal/codex-sandbox-controller.ts → agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts} +2 -2
  72. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +3 -5
  73. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +270 -224
  74. package/lib/agent-clients/adapters/codex-orchestration.ts +632 -0
  75. package/lib/agent-clients/adapters/codex.ts +44 -1
  76. package/lib/agent-clients/adapters/index.ts +7 -20
  77. package/lib/agent-clients/tokens.ts +4 -3
  78. package/lib/internal/agent-client.ts +142 -73
  79. package/lib/internal/cli-route-inventory.ts +2 -2
  80. package/lib/internal/sandbox-control.ts +0 -97
  81. package/lib/internal/task-finalization.ts +30 -18
  82. package/lib/internal/task-lifecycle.ts +1 -1
  83. package/lib/internal/task-operation-registry.ts +1 -1
  84. package/lib/internal/task-validate.ts +1 -1
  85. package/lib/platform/github-client.ts +34 -3
  86. package/lib/platform/issues.ts +18 -6
  87. package/lib/runtime/agent-runtime.ts +6 -41
  88. package/lib/sandbox/commands/control-status.ts +1 -0
  89. package/lib/sandbox/commands/create.ts +7 -1
  90. package/lib/sandbox/commands/ls.ts +4 -5
  91. package/lib/sandbox/commands/rm.ts +1 -1
  92. package/lib/sandbox/control/agent-operation.ts +49 -0
  93. package/lib/sandbox/control/client.ts +20 -254
  94. package/lib/sandbox/control/executor.ts +13 -136
  95. package/lib/sandbox/control/lifecycle.ts +67 -4
  96. package/lib/sandbox/control/protocol.ts +22 -66
  97. package/lib/sandbox/control/server.ts +21 -95
  98. package/lib/sandbox/control/task-view.ts +0 -19
  99. package/lib/sandbox/mounts.ts +10 -1
  100. package/lib/sandbox/recovery.ts +38 -17
  101. package/lib/sandbox/removal.ts +33 -7
  102. package/lib/sandbox/workspace-identity.ts +16 -6
  103. package/lib/sandbox/workspace-view.ts +50 -18
  104. package/lib/task/commands/ls.ts +3 -3
  105. package/lib/task/control-authority.ts +66 -378
  106. package/lib/task/control-recovery.ts +3 -4
  107. package/lib/task/create.ts +3 -3
  108. package/lib/task/delegation-receipts.ts +87 -231
  109. package/lib/task/events.ts +100 -42
  110. package/lib/task/finalization.ts +37 -7
  111. package/lib/task/lifecycle.ts +257 -109
  112. package/lib/task/orchestration.ts +260 -237
  113. package/lib/task/runtime-paths.ts +12 -0
  114. package/lib/task/short-id.ts +33 -40
  115. package/lib/task/verification-engine.ts +30 -25
  116. package/lib/task/workspace-snapshot.ts +1 -1
  117. package/package.json +1 -1
  118. package/templates/.agents/hooks/lifecycle-delegation.js +1 -25
  119. package/templates/.agents/rules/README.en.md +2 -1
  120. package/templates/.agents/rules/README.zh-CN.md +2 -1
  121. package/templates/.agents/rules/next-step-output.en.md +6 -19
  122. package/templates/.agents/rules/next-step-output.zh-CN.md +6 -19
  123. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  124. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  125. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  126. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  127. package/templates/.agents/skills/check-task/SKILL.en.md +1 -1
  128. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +1 -1
  129. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  130. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  131. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  132. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  133. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +0 -1
  134. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +0 -1
  135. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -29
  136. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -26
  137. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  138. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  139. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  140. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  141. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  142. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  143. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  144. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  145. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
  146. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
  147. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  148. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  149. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +0 -546
  150. package/dist/lib/internal/codex-lifecycle.js +0 -385
  151. package/dist/lib/task/codex-orchestration.js +0 -206
  152. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +0 -776
  153. package/lib/internal/codex-lifecycle.ts +0 -390
  154. package/lib/task/codex-orchestration.ts +0 -276
@@ -18,13 +18,14 @@ import { validateCurrentTaskContract } from './current-contract.ts';
18
18
  import { isValidAgentInfraVersion } from '../version.ts';
19
19
  import { taskIssueIdentity } from '../platform/task-identities.ts';
20
20
  import { resourceIdentityEquals } from '../platform/resource-identity.ts';
21
+ import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
21
22
 
22
23
  const lifecycleIntentCatalog = [
23
24
  'block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'
24
25
  ] as const;
25
26
  const lifecycleFailureCatalog = [
27
+ 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED',
26
28
  'LIFECYCLE_DIRECTORY_RENAME_FAILED',
27
- 'LIFECYCLE_DIRECTORY_COPY_FAILED',
28
29
  'LIFECYCLE_DOCUMENT_INVALID',
29
30
  'LIFECYCLE_FINAL_STATE_INVALID',
30
31
  'LIFECYCLE_IDENTITY_CONFLICT',
@@ -38,6 +39,7 @@ const lifecycleFailureCatalog = [
38
39
  'LIFECYCLE_PAYLOAD_INVALID',
39
40
  'LIFECYCLE_SHORT_ID_FAILED',
40
41
  'LIFECYCLE_SHORT_ID_PRECONDITION',
42
+ 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
41
43
  'LIFECYCLE_SOURCE_INVALID',
42
44
  'LIFECYCLE_STAGING_IDENTITY_INVALID',
43
45
  'LIFECYCLE_STAGING_INVALID',
@@ -116,6 +118,9 @@ type TaskLifecycleOptions = {
116
118
  type TaskCompletionProjection = Readonly<{ taskId: string; content: string; timestamp: string; agentInfraVersion: string }>;
117
119
 
118
120
  const STEPS: readonly LifecycleStep[] = ['task-written', 'directory-moved', 'registry-committed'];
121
+ function stepsForIntent(intent: TaskLifecycleIntent): readonly LifecycleStep[] {
122
+ return intent === 'complete' ? ['task-written', 'directory-moved'] : STEPS;
123
+ }
119
124
  const DEFAULT_IO: LifecycleFileSystem = {
120
125
  renameSync: (source, target) => fs.renameSync(source, target),
121
126
  readFileSync: (file) => fs.readFileSync(file, 'utf8'),
@@ -129,7 +134,7 @@ function failed(request: Pick<TaskLifecycleRequest, 'taskRef' | 'intent'>, error
129
134
  intent: request.intent, sourceState: null, targetState: null, sourcePath: null,
130
135
  targetPath: null, task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
131
136
  shortId: { effect: 'unchanged', shortId: null, changed: false }, timestamp: null,
132
- agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: STEPS,
137
+ agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: stepsForIntent(request.intent),
133
138
  error, ...extra
134
139
  };
135
140
  }
@@ -200,9 +205,96 @@ function transition(request: TaskLifecycleRequest): { sources: readonly SourceSt
200
205
  if (request.intent === 'activate') return { sources: ['blocked'], target: 'active', registry: 'alloc' };
201
206
  if (request.intent === 'cancel') return { sources: ['active', 'blocked'], target: 'completed', registry: 'release' };
202
207
  if (request.intent === 'restore') return { sources: ['staging'], target: 'active', registry: 'alloc' };
208
+ if (request.intent === 'complete') return { sources: ['active'], target: 'completed', registry: 'none' };
203
209
  return { sources: ['active'], target: 'completed', registry: 'release' };
204
210
  }
205
211
 
212
+ function sameTaskEntry(source: string, target: string): boolean {
213
+ const sourceStat = fs.lstatSync(source);
214
+ let targetStat: fs.Stats;
215
+ try { targetStat = fs.lstatSync(target); }
216
+ catch (error) {
217
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
218
+ throw error;
219
+ }
220
+ if (sourceStat.isSymbolicLink() || targetStat.isSymbolicLink()) {
221
+ return sourceStat.isSymbolicLink() && targetStat.isSymbolicLink()
222
+ && fs.readlinkSync(source) === fs.readlinkSync(target);
223
+ }
224
+ if (sourceStat.isDirectory() && targetStat.isDirectory()) {
225
+ const sourceNames = fs.readdirSync(source).sort();
226
+ const targetNames = fs.readdirSync(target).sort();
227
+ return sourceNames.join('\0') === targetNames.join('\0')
228
+ && sourceNames.every((name) => sameTaskEntry(path.join(source, name), path.join(target, name)))
229
+ && (sourceStat.mode & 0o777) === (targetStat.mode & 0o777);
230
+ }
231
+ return sourceStat.isFile() && targetStat.isFile()
232
+ && (sourceStat.mode & 0o777) === (targetStat.mode & 0o777)
233
+ && fs.readFileSync(source).equals(fs.readFileSync(target));
234
+ }
235
+
236
+ function copyTaskEntry(source: string, target: string): void {
237
+ const sourceStat = fs.lstatSync(source);
238
+ if (fs.existsSync(target)) {
239
+ const targetStat = fs.lstatSync(target);
240
+ if (sourceStat.isDirectory() && targetStat.isDirectory() && !targetStat.isSymbolicLink()) {
241
+ for (const name of fs.readdirSync(source).sort()) copyTaskEntry(path.join(source, name), path.join(target, name));
242
+ fs.chmodSync(target, sourceStat.mode & 0o777);
243
+ return;
244
+ }
245
+ if (sameTaskEntry(source, target)) return;
246
+ throw new Error(`task content conflict at ${path.relative(path.dirname(source), source)}`);
247
+ }
248
+ if (sourceStat.isSymbolicLink()) {
249
+ fs.symlinkSync(fs.readlinkSync(source), target);
250
+ return;
251
+ }
252
+ if (sourceStat.isDirectory()) {
253
+ fs.mkdirSync(target, { mode: sourceStat.mode & 0o777 });
254
+ for (const name of fs.readdirSync(source).sort()) copyTaskEntry(path.join(source, name), path.join(target, name));
255
+ fs.chmodSync(target, sourceStat.mode & 0o777);
256
+ return;
257
+ }
258
+ if (!sourceStat.isFile()) throw new Error(`unsupported task entry: ${source}`);
259
+ fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
260
+ fs.chmodSync(target, sourceStat.mode & 0o777);
261
+ }
262
+
263
+ function transferTaskContents(sourcePath: string, targetPath: string): void {
264
+ const sourceRoot = fs.lstatSync(sourcePath);
265
+ if (!sourceRoot.isDirectory() || sourceRoot.isSymbolicLink()) throw new Error('task source is not a real directory');
266
+ const targetRoot = fs.lstatSync(targetPath);
267
+ if (!targetRoot.isDirectory() || targetRoot.isSymbolicLink()) throw new Error('completed target is not a real directory');
268
+ for (const name of fs.readdirSync(sourcePath).sort()) {
269
+ copyTaskEntry(path.join(sourcePath, name), path.join(targetPath, name));
270
+ }
271
+ const sourceNames = fs.readdirSync(sourcePath).sort();
272
+ const targetNames = fs.readdirSync(targetPath).sort();
273
+ if (sourceNames.join('\0') !== targetNames.join('\0')
274
+ || !sourceNames.every((name) => sameTaskEntry(path.join(sourcePath, name), path.join(targetPath, name)))) {
275
+ throw new Error('completed target does not match task source after copy');
276
+ }
277
+ }
278
+
279
+ function targetContainsTask(targetPath: string, taskId: string, request: TaskLifecycleRequest): boolean {
280
+ try {
281
+ const stat = fs.lstatSync(targetPath);
282
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
283
+ const entries = fs.readdirSync(targetPath);
284
+ if (entries.length === 0) return true;
285
+ const taskPath = path.join(targetPath, 'task.md');
286
+ const journalPath = path.join(targetPath, '.task-lifecycle.json');
287
+ if (!fs.existsSync(taskPath) || !fs.existsSync(journalPath)) return false;
288
+ const frontmatter = parseTypedTaskFrontmatter(fs.readFileSync(taskPath, 'utf8'));
289
+ const journal = parseJournal(fs.readFileSync(journalPath, 'utf8'));
290
+ return frontmatter.id === taskId && frontmatter.status === 'completed'
291
+ && journal.taskId === taskId && journal.intent === request.intent
292
+ && journal.intentDigest === intentIdentity(request, taskId);
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
206
298
  function actionAndNote(request: TaskLifecycleRequest, restoredFiles = 0): { action: string; note: string } {
207
299
  if (request.intent === 'block') return { action: 'Block Task', note: request.reason };
208
300
  if (request.intent === 'activate') return { action: 'Activate Task', note: request.note };
@@ -400,13 +492,13 @@ export function inspectTaskLifecycleProgress(
400
492
  if (journal.taskId !== taskId || journal.intent !== 'complete'
401
493
  || journal.intentDigest !== intentIdentity(request, taskId)
402
494
  || journal.sourceState !== 'active' || journal.targetState !== 'completed') return 'unknown';
403
- if (journal.completedSteps.some((step, index) => STEPS[index] !== step)) return 'unknown';
495
+ if (journal.completedSteps.some((step, index) => stepsForIntent(journal.intent)[index] !== step)) return 'unknown';
404
496
  const directoryMoved = journal.completedSteps.includes('directory-moved');
405
497
  const registryCommitted = journal.completedSteps.includes('registry-committed');
406
498
  const hasShortId = loadShortIdByTaskId(repoRoot).has(taskId);
407
499
  if (state === 'active' && (directoryMoved || !hasShortId)) return 'unknown';
408
500
  if (state === 'completed' && (!journal.completedSteps.includes('task-written')
409
- || !matchingCompletion(content, request) || (registryCommitted && hasShortId))) return 'unknown';
501
+ || !matchingCompletion(content, request) || registryCommitted || !hasShortId)) return 'unknown';
410
502
  if (frontmatter.status !== state
411
503
  && !(state === 'active' && frontmatter.status === 'completed' && matchingCompletion(content, request))) {
412
504
  return 'unknown';
@@ -417,63 +509,7 @@ export function inspectTaskLifecycleProgress(
417
509
  }
418
510
  }
419
511
 
420
- type DirectoryEntry = Readonly<{ relativePath: string; kind: 'file' | 'directory'; size: number; digest: string }>;
421
-
422
- function directoryManifest(root: string): DirectoryEntry[] {
423
- const entries: DirectoryEntry[] = [];
424
- const visit = (current: string, relative = '') => {
425
- for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
426
- const relativePath = relative ? path.join(relative, entry.name) : entry.name;
427
- const absolute = path.join(current, entry.name);
428
- const stat = fs.lstatSync(absolute);
429
- if (stat.isSymbolicLink()) throw new Error(`symbolic link is not allowed in lifecycle directory: ${relativePath}`);
430
- if (entry.isDirectory()) {
431
- entries.push({ relativePath, kind: 'directory', size: 0, digest: '' });
432
- visit(absolute, relativePath);
433
- } else if (entry.isFile()) {
434
- const digest = createHash('sha256').update(fs.readFileSync(absolute)).digest('hex');
435
- entries.push({ relativePath, kind: 'file', size: stat.size, digest });
436
- } else {
437
- throw new Error(`unsupported lifecycle directory entry: ${relativePath}`);
438
- }
439
- }
440
- };
441
- visit(root);
442
- return entries;
443
- }
444
-
445
- function copyDirectoryVerified(source: string, target: string): void {
446
- if (fs.existsSync(target)) throw new Error(`target directory already exists: ${target}`);
447
- const copy = (currentSource: string, currentTarget: string) => {
448
- fs.mkdirSync(currentTarget);
449
- for (const entry of fs.readdirSync(currentSource, { withFileTypes: true })) {
450
- const sourcePath = path.join(currentSource, entry.name);
451
- const targetPath = path.join(currentTarget, entry.name);
452
- const stat = fs.lstatSync(sourcePath);
453
- if (stat.isSymbolicLink()) throw new Error(`symbolic link is not allowed in lifecycle directory: ${entry.name}`);
454
- if (entry.isDirectory()) copy(sourcePath, targetPath);
455
- else if (entry.isFile()) fs.copyFileSync(sourcePath, targetPath);
456
- else throw new Error(`unsupported lifecycle directory entry: ${entry.name}`);
457
- }
458
- };
459
- copy(source, target);
460
- const sourceManifest = directoryManifest(source);
461
- const targetManifest = directoryManifest(target);
462
- if (JSON.stringify(sourceManifest) !== JSON.stringify(targetManifest)) {
463
- throw new Error('copied lifecycle directory failed manifest verification');
464
- }
465
- }
466
-
467
- function removeCopiedSource(source: string, target: string): void {
468
- const sourceManifest = directoryManifest(source);
469
- const targetManifest = directoryManifest(target);
470
- if (JSON.stringify(sourceManifest) !== JSON.stringify(targetManifest)) {
471
- throw new Error('lifecycle source and target diverged before source cleanup');
472
- }
473
- fs.rmSync(source, { recursive: true, force: false });
474
- }
475
-
476
- function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLifecycleOptions = {}): TaskLifecycleResult {
512
+ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options: TaskLifecycleOptions = {}): TaskLifecycleResult {
477
513
  if (requestInput.intent === 'recover-started') {
478
514
  return failed(requestInput, { code: 'LIFECYCLE_RECOVERY_REQUIRES_AUTHORITY', message: 'recover-started must be dispatched through task control authority' });
479
515
  }
@@ -493,7 +529,12 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
493
529
  }
494
530
  const spec = transition(request);
495
531
  const hot = locateHotTaskDirs(repoRoot, taskId);
496
- if (hot.length > 1) return failed(request, { code: 'LIFECYCLE_IDENTITY_CONFLICT', message: `task ${taskId} exists in multiple hot states` }, { taskId });
532
+ const activeSource = hot.find((candidate) => candidate.state === 'active');
533
+ const completedTarget = hot.find((candidate) => candidate.state === 'completed');
534
+ const transferInProgress = request.intent === 'complete' && Boolean(activeSource && completedTarget)
535
+ && targetContainsTask(completedTarget!.taskDir, taskId, request)
536
+ && fs.existsSync(path.join(activeSource!.taskDir, '.task-lifecycle.json'));
537
+ if (hot.length > 1 && !transferInProgress) return failed(request, { code: 'LIFECYCLE_IDENTITY_CONFLICT', message: `task ${taskId} exists in multiple hot states` }, { taskId });
497
538
  let sourceState: SourceState;
498
539
  let sourcePath: string;
499
540
  let restoredFiles = 0;
@@ -501,6 +542,9 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
501
542
  const validation = validateRestoreStaging(request, repoRoot, taskId);
502
543
  if ('code' in validation) return failed(request, validation, { taskId });
503
544
  sourceState = 'staging'; sourcePath = validation.stagingDir; restoredFiles = validation.fileCount;
545
+ } else if (activeSource && request.intent === 'complete') {
546
+ sourceState = activeSource.state; sourcePath = activeSource.taskDir;
547
+ restoredFiles = fs.readdirSync(sourcePath).filter((name) => !name.startsWith('.')).length;
504
548
  } else if (hot[0]) {
505
549
  sourceState = hot[0].state; sourcePath = hot[0].taskDir;
506
550
  restoredFiles = fs.readdirSync(sourcePath).filter((name) => !name.startsWith('.')).length;
@@ -508,6 +552,37 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
508
552
  return failed(request, { code: 'LIFECYCLE_TASK_NOT_FOUND', message: `task ${taskId} has no lifecycle source` }, { taskId });
509
553
  }
510
554
  const targetPath = path.join(repoRoot, '.agents', 'workspace', spec.target, taskId);
555
+ const requiredSteps = stepsForIntent(request.intent);
556
+ const movingTaskDirectory = sourcePath !== targetPath;
557
+ const assertRuntimeMoveSafe = (): LifecycleError | null => {
558
+ if (!movingTaskDirectory) return null;
559
+ const controlManifest = path.join(sourcePath, '.runtime', 'sandbox-control', 'manifest.json');
560
+ const controlLease = path.join(sourcePath, '.runtime', 'sandbox-control', 'lease.json');
561
+ if (request.intent !== 'complete' && (fs.existsSync(controlManifest) || fs.existsSync(controlLease))) {
562
+ return {
563
+ code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
564
+ message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory'
565
+ };
566
+ }
567
+ const orchestrationPath = path.join(sourcePath, '.runtime', 'orchestration.json');
568
+ if (fs.existsSync(orchestrationPath)) {
569
+ try {
570
+ const run = JSON.parse(fs.readFileSync(orchestrationPath, 'utf8')) as { status?: unknown; pendingDelegation?: unknown };
571
+ if (run.status === 'running' || run.pendingDelegation !== null && run.pendingDelegation !== undefined) {
572
+ return {
573
+ code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
574
+ message: 'task orchestration has an active or pending delegation; settle it before moving the task directory'
575
+ };
576
+ }
577
+ } catch {
578
+ return {
579
+ code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
580
+ message: 'task orchestration state is unreadable; refusing to move the task directory'
581
+ };
582
+ }
583
+ }
584
+ return null;
585
+ };
511
586
  const currentTaskFile = path.join(sourcePath, 'task.md');
512
587
  const existingJournal = fs.existsSync(path.join(sourcePath, '.task-lifecycle.json'));
513
588
  if (sourceState === spec.target && !existingJournal) {
@@ -517,7 +592,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
517
592
  return {
518
593
  ...failed(request, { code: '', message: '' }), status: 'no-op', error: null, taskId,
519
594
  sourceState, targetState: spec.target, sourcePath, targetPath, pendingSteps: [],
520
- completedSteps: STEPS, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
595
+ completedSteps: requiredSteps, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
521
596
  agentInfraVersion: typeof frontmatter.agent_infra_version === 'string' ? frontmatter.agent_infra_version : null
522
597
  };
523
598
  }
@@ -526,9 +601,12 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
526
601
  if (!spec.sources.includes(sourceState) && !existingJournal) {
527
602
  return failed(request, { code: 'LIFECYCLE_SOURCE_INVALID', message: `${request.intent} is not allowed from ${sourceState}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
528
603
  }
529
- if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal) {
604
+ if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal
605
+ && !(request.intent === 'complete' && targetContainsTask(targetPath, taskId, request))) {
530
606
  return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
531
607
  }
608
+ const moveSafety = assertRuntimeMoveSafe();
609
+ if (moveSafety) return failed(request, moveSafety, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
532
610
  const content = io.readFileSync(currentTaskFile);
533
611
  let frontmatter;
534
612
  try { frontmatter = parseTypedTaskFrontmatter(content); }
@@ -548,7 +626,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
548
626
  }
549
627
  const registry = JSON.parse(registryRequest.output || '{"version":1,"ids":{}}') as { ids: Record<string, string> };
550
628
  const registeredKey = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId)?.[0];
551
- if (spec.registry === 'release' && sourceState === 'active' && !registeredKey) {
629
+ if ((spec.registry === 'release' || request.intent === 'complete') && sourceState === 'active' && !registeredKey) {
552
630
  return failed(request, { code: 'LIFECYCLE_SHORT_ID_PRECONDITION', message: 'active source has no short-id registry entry' }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
553
631
  }
554
632
  if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1) {
@@ -567,7 +645,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
567
645
  }
568
646
  if (request.dryRun) {
569
647
  if (!journal.failure) 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 });
570
- 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)) });
648
+ 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)) });
571
649
  }
572
650
  if (journal.intentDigest !== digest) 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 });
573
651
  } else {
@@ -608,7 +686,8 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
608
686
  const completed = new Set(journal.completedSteps);
609
687
  if (sourceState === spec.target && journal.sourceState !== spec.target) completed.add('directory-moved');
610
688
  const currentMappings = loadShortIdByTaskId(repoRoot);
611
- if ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId))) {
689
+ if (requiredSteps.includes('registry-committed')
690
+ && ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId)))) {
612
691
  if (completed.has('directory-moved')) completed.add('registry-committed');
613
692
  }
614
693
  let taskOperations: readonly TaskOperationSummary[] = [];
@@ -619,7 +698,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
619
698
  task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
620
699
  shortId: { effect: 'unchanged', shortId: null, changed: false },
621
700
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
622
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
701
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
623
702
  };
624
703
  }
625
704
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles)) completed.add('task-written');
@@ -648,57 +727,82 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
648
727
  task: { operations: taskOperations }, directory: { effect: 'unchanged', changed: false },
649
728
  shortId: { effect: 'unchanged', shortId: null, changed: false },
650
729
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
651
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
730
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
652
731
  };
653
732
  }
654
- if (fs.existsSync(targetPath) && !fs.existsSync(sourcePath)) completed.add('directory-moved');
733
+ if (fs.existsSync(targetPath) && !fs.existsSync(path.join(sourcePath, 'task.md'))) completed.add('directory-moved');
655
734
  if (!completed.has('directory-moved')) {
735
+ const moveSafety = assertRuntimeMoveSafe();
736
+ if (moveSafety) 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 });
656
737
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
657
- if (fs.existsSync(targetPath)) 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 });
658
- try { (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath); }
659
- catch (error) {
660
- if ((error as { code?: string }).code === 'EXDEV') {
661
- try {
662
- copyDirectoryVerified(sourcePath, targetPath);
663
- completed.add('directory-moved');
664
- journal.completedSteps = [...completed];
665
- const copiedJournalPath = path.join(targetPath, '.task-lifecycle.json');
666
- writeJournal(initialJournalPath, journal, io);
667
- writeJournal(copiedJournalPath, journal, io);
668
- removeCopiedSource(sourcePath, targetPath);
669
- journalPath = copiedJournalPath;
670
- } catch (copyError) {
671
- if (fs.existsSync(targetPath)) fs.rmSync(targetPath, { recursive: true, force: false });
672
- const failure = { code: 'LIFECYCLE_DIRECTORY_COPY_FAILED', message: String(copyError) } as const;
673
- rememberJournalFailure(initialJournalPath, journal, io, failure);
674
- return failed(request, failure, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath: initialJournalPath, task: { operations: taskOperations }, changed: true, completedSteps: [...completed], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion });
738
+ if (request.intent === 'complete') {
739
+ try {
740
+ if (!fs.existsSync(targetPath)) fs.mkdirSync(targetPath, { recursive: false, mode: 0o700 });
741
+ if (!targetContainsTask(targetPath, taskId, request)) {
742
+ throw new Error('completed target has no matching task identity');
743
+ }
744
+ transferTaskContents(sourcePath, targetPath);
745
+ for (const name of fs.readdirSync(sourcePath).filter((entry) => entry !== '.task-lifecycle.json' && entry !== 'task.md').sort()) {
746
+ fs.rmSync(path.join(sourcePath, name), { recursive: true, force: false });
675
747
  }
676
- } else {
677
- const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) } as const;
678
- rememberJournalFailure(journalPath, journal, io, failure);
679
- 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 });
748
+ fs.rmSync(path.join(sourcePath, 'task.md'), { force: false });
749
+ fs.rmSync(path.join(sourcePath, '.task-lifecycle.json'), { force: false });
750
+ } catch (error) {
751
+ const failure = { code: 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED', message: String(error) } as const;
752
+ const sourceJournalPath = path.join(sourcePath, '.task-lifecycle.json');
753
+ rememberJournalFailure(sourceJournalPath, journal, io, failure);
754
+ return failed(request, failure, {
755
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath,
756
+ journalPath: sourceJournalPath, task: { operations: taskOperations },
757
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
758
+ pendingSteps: requiredSteps.filter((step) => !completed.has(step) || step === 'directory-moved'),
759
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
760
+ });
680
761
  }
681
- }
682
- if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
762
+ completed.add('directory-moved');
763
+ journal.completedSteps = [...completed];
683
764
  journalPath = path.join(targetPath, '.task-lifecycle.json');
684
- } else if (!completed.has('directory-moved')) {
685
- completed.add('directory-moved'); journal.completedSteps = [...completed]; journalPath = path.join(targetPath, '.task-lifecycle.json');
686
765
  try { writeJournal(journalPath, journal, io); }
687
- catch (error) { 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 }); }
766
+ catch (error) {
767
+ return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, {
768
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath,
769
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
770
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
771
+ pendingSteps: requiredSteps.filter((step) => step !== 'task-written'),
772
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
773
+ });
774
+ }
775
+ } else {
776
+ if (fs.existsSync(targetPath)) 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 });
777
+ try { (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath); }
778
+ catch (error) {
779
+ const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) } as const;
780
+ rememberJournalFailure(journalPath, journal, io, failure);
781
+ 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 });
782
+ }
783
+ if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
784
+ journalPath = path.join(targetPath, '.task-lifecycle.json');
785
+ } else if (!completed.has('directory-moved')) {
786
+ completed.add('directory-moved'); journal.completedSteps = [...completed]; journalPath = path.join(targetPath, '.task-lifecycle.json');
787
+ try { writeJournal(journalPath, journal, io); }
788
+ catch (error) { 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 }); }
789
+ }
688
790
  }
689
791
  }
690
- if (completed.has('directory-moved') && sourcePath !== targetPath && fs.existsSync(sourcePath) && fs.existsSync(targetPath)) {
691
- try {
692
- removeCopiedSource(sourcePath, targetPath);
693
- journalPath = path.join(targetPath, '.task-lifecycle.json');
694
- } catch (error) {
695
- const failure = { code: 'LIFECYCLE_DIRECTORY_COPY_FAILED', message: String(error) } as const;
696
- rememberJournalFailure(journalPath, journal, io, failure);
697
- 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 });
792
+ const activeTaskPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
793
+ if (request.intent === 'complete' && fs.existsSync(activeTaskPath)) {
794
+ try { fs.rmdirSync(activeTaskPath); }
795
+ catch (error) {
796
+ return failed(request, { code: 'LIFECYCLE_SOURCE_DIRECTORY_CLEANUP_FAILED', message: String(error) }, {
797
+ taskId, sourceState, targetState: spec.target, sourcePath: activeTaskPath, targetPath, journalPath,
798
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
799
+ changed: true, completedSteps: [...completed], pendingSteps: [],
800
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
801
+ });
698
802
  }
699
803
  }
700
- let shortId: TaskLifecycleResult['shortId'] = { effect: 'unchanged', shortId: null, changed: false };
701
- if (!completed.has('registry-committed')) {
804
+ let shortId: TaskLifecycleResult['shortId'] = { effect: 'unchanged', shortId: request.intent === 'complete' ? registeredKey ?? null : null, changed: false };
805
+ if (requiredSteps.includes('registry-committed') && !completed.has('registry-committed')) {
702
806
  try { shortId = mutateShortIdRegistry(repoRoot, taskId, spec.registry); }
703
807
  catch (error) { return failed(request, { code: (error as { code?: string }).code ?? 'LIFECYCLE_SHORT_ID_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 }); }
704
808
  completed.add('registry-committed'); journal.completedSteps = [...completed];
@@ -707,7 +811,7 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
707
811
  }
708
812
  const mappings = loadShortIdByTaskId(repoRoot);
709
813
  if (spec.target === 'active' && !mappings.has(taskId)) 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 });
710
- if (spec.target !== 'active' && mappings.has(taskId)) 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 });
814
+ if (request.intent !== 'complete' && spec.target !== 'active' && mappings.has(taskId)) 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 });
711
815
  try { io.unlinkSync(journalPath); }
712
816
  catch (error) { return failed(request, { code: 'LIFECYCLE_JOURNAL_CLEANUP_FAILED', message: String(error) }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath, task: { operations: taskOperations }, directory: { effect: 'move', changed: true }, shortId, changed: true, completedSteps: [...completed], pendingSteps: [], timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion }); }
713
817
  return {
@@ -719,7 +823,51 @@ function applyTaskLifecycle(requestInput: TaskLifecycleRequest, options: TaskLif
719
823
  };
720
824
  }
721
825
 
722
- export { lifecycleIntentCatalog, lifecycleFailureCatalog, lifecycleProducerCatalog, applyTaskLifecycle, previewTaskCompletion };
826
+ function applyTaskLifecycle(request: TaskLifecycleRequest, options: TaskLifecycleOptions = {}): TaskLifecycleResult {
827
+ const normalized = normalizedRequest(request);
828
+ if ('code' in normalized) return applyTaskLifecycleUnlocked(request, options);
829
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
830
+ const taskId = normalized.intent === 'restore'
831
+ ? normalized.taskRef
832
+ : (() => {
833
+ const resolved = resolveTaskRef(normalized.taskRef, { repoRoot });
834
+ return resolved.ok ? resolved.taskId : null;
835
+ })();
836
+ if (!taskId) return applyTaskLifecycleUnlocked(request, options);
837
+ try {
838
+ return withTaskExecutionLock(repoRoot, taskId, 'task-lifecycle.move', () => applyTaskLifecycleUnlocked(request, options));
839
+ } catch (error) {
840
+ if (error instanceof TaskExecutionLockError) {
841
+ return failed(request, { code: error.code, message: error.message }, { taskId });
842
+ }
843
+ throw error;
844
+ }
845
+ }
846
+
847
+ function applyTaskLifecycleWithinTaskLock(
848
+ request: TaskLifecycleRequest,
849
+ options: TaskLifecycleOptions,
850
+ lockedTaskId: string
851
+ ): TaskLifecycleResult {
852
+ const normalized = normalizedRequest(request);
853
+ if ('code' in normalized) return failed(request, normalized);
854
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
855
+ const resolved = normalized.intent === 'restore'
856
+ ? { ok: true as const, taskId: normalized.taskRef }
857
+ : resolveTaskRef(normalized.taskRef, { repoRoot });
858
+ if (!resolved.ok || resolved.taskId !== lockedTaskId) {
859
+ return failed(request, {
860
+ code: 'LIFECYCLE_LOCK_SCOPE_MISMATCH',
861
+ message: 'task lifecycle request does not match the held task lock'
862
+ }, { taskId: resolved.ok ? resolved.taskId : resolved.taskId });
863
+ }
864
+ return applyTaskLifecycleUnlocked(request, options);
865
+ }
866
+
867
+ export {
868
+ lifecycleIntentCatalog, lifecycleFailureCatalog, lifecycleProducerCatalog,
869
+ applyTaskLifecycle, applyTaskLifecycleWithinTaskLock, previewTaskCompletion
870
+ };
723
871
  export type {
724
872
  TaskLifecycleIntent, TaskLifecycleRequest, TaskLifecycleResult, TaskLifecycleOptions,
725
873
  LifecycleError, LifecycleJournal, LifecycleStep, TaskCompletionProjection