@fitlab-ai/agent-infra 0.11.12 → 0.11.14

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 (120) hide show
  1. package/dist/bin/internal-cli.js +2 -7
  2. package/dist/lib/agent-clients/adapter.js +0 -3
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +5 -9
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/cli.js +1 -5
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/recovery.js +1 -5
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
  7. package/dist/lib/agent-clients/adapters/codex-orchestration.js +1 -3
  8. package/dist/lib/agent-clients/adapters/codex.js +0 -2
  9. package/dist/lib/internal/cli-route-inventory.js +0 -1
  10. package/dist/lib/internal/sandbox-control.js +1 -81
  11. package/dist/lib/internal/task-finalization.js +29 -20
  12. package/dist/lib/internal/task-lifecycle.js +4 -2
  13. package/dist/lib/internal/task-operation-registry.js +1 -3
  14. package/dist/lib/internal/task-validate.js +14 -124
  15. package/dist/lib/platform/github-client.js +33 -3
  16. package/dist/lib/platform/release-notes.js +2 -2
  17. package/dist/lib/sandbox/commands/control-status.js +0 -1
  18. package/dist/lib/sandbox/commands/create.js +11 -6
  19. package/dist/lib/sandbox/commands/ls.js +4 -5
  20. package/dist/lib/sandbox/commands/rm.js +1 -1
  21. package/dist/lib/sandbox/config.js +12 -0
  22. package/dist/lib/sandbox/control/audit.js +1 -2
  23. package/dist/lib/sandbox/control/client.js +15 -110
  24. package/dist/lib/sandbox/control/executor.js +9 -59
  25. package/dist/lib/sandbox/control/host-recovery.js +89 -27
  26. package/dist/lib/sandbox/control/lifecycle.js +75 -9
  27. package/dist/lib/sandbox/control/protocol.js +11 -50
  28. package/dist/lib/sandbox/control/server.js +203 -253
  29. package/dist/lib/sandbox/control/task-view.js +0 -13
  30. package/dist/lib/sandbox/environment.js +7 -8
  31. package/dist/lib/sandbox/mounts.js +71 -52
  32. package/dist/lib/sandbox/recovery.js +30 -12
  33. package/dist/lib/sandbox/removal.js +37 -3
  34. package/dist/lib/sandbox/workspace-identity.js +18 -6
  35. package/dist/lib/sandbox/workspace-view.js +89 -6
  36. package/dist/lib/task/commands/ls.js +3 -3
  37. package/dist/lib/task/control-authority.js +41 -17
  38. package/dist/lib/task/control-recovery.js +2 -4
  39. package/dist/lib/task/finalization.js +27 -2
  40. package/dist/lib/task/lifecycle.js +294 -63
  41. package/dist/lib/task/orchestration.js +26 -72
  42. package/dist/lib/task/short-id.js +37 -27
  43. package/dist/lib/task/verification-engine.js +4 -11
  44. package/dist/lifecycle-build-manifest.json +3 -3
  45. package/lib/agent-clients/adapter.ts +0 -27
  46. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +6 -12
  47. package/lib/agent-clients/adapters/codex-lifecycle/cli.ts +1 -5
  48. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +0 -7
  49. package/lib/agent-clients/adapters/codex-lifecycle/recovery.ts +1 -5
  50. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +21 -27
  51. package/lib/agent-clients/adapters/codex-orchestration.ts +1 -3
  52. package/lib/agent-clients/adapters/codex.ts +0 -2
  53. package/lib/internal/cli-route-inventory.ts +0 -1
  54. package/lib/internal/sandbox-control.ts +0 -97
  55. package/lib/internal/task-finalization.ts +33 -19
  56. package/lib/internal/task-lifecycle.ts +4 -2
  57. package/lib/internal/task-operation-registry.ts +1 -3
  58. package/lib/internal/task-validate.ts +13 -127
  59. package/lib/platform/github-client.ts +34 -3
  60. package/lib/platform/release-notes.ts +2 -2
  61. package/lib/sandbox/commands/control-status.ts +0 -1
  62. package/lib/sandbox/commands/create.ts +11 -5
  63. package/lib/sandbox/commands/ls.ts +4 -5
  64. package/lib/sandbox/commands/rm.ts +1 -1
  65. package/lib/sandbox/config.ts +13 -0
  66. package/lib/sandbox/control/audit.ts +1 -2
  67. package/lib/sandbox/control/client.ts +17 -138
  68. package/lib/sandbox/control/executor.ts +8 -65
  69. package/lib/sandbox/control/host-recovery.ts +94 -21
  70. package/lib/sandbox/control/lifecycle.ts +75 -10
  71. package/lib/sandbox/control/protocol.ts +12 -62
  72. package/lib/sandbox/control/server.ts +11 -61
  73. package/lib/sandbox/control/task-view.ts +0 -19
  74. package/lib/sandbox/environment.ts +7 -8
  75. package/lib/sandbox/mounts.ts +93 -68
  76. package/lib/sandbox/recovery.ts +37 -19
  77. package/lib/sandbox/removal.ts +36 -5
  78. package/lib/sandbox/workspace-identity.ts +16 -6
  79. package/lib/sandbox/workspace-view.ts +81 -7
  80. package/lib/task/commands/ls.ts +3 -3
  81. package/lib/task/control-authority.ts +41 -26
  82. package/lib/task/control-recovery.ts +3 -5
  83. package/lib/task/finalization.ts +29 -2
  84. package/lib/task/lifecycle.ts +282 -57
  85. package/lib/task/orchestration.ts +30 -68
  86. package/lib/task/short-id.ts +33 -40
  87. package/lib/task/verification-engine.ts +3 -9
  88. package/package.json +1 -1
  89. package/templates/.agents/rules/README.en.md +1 -0
  90. package/templates/.agents/rules/README.zh-CN.md +1 -0
  91. package/templates/.agents/rules/lifecycle-orchestration.en.md +3 -4
  92. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +3 -4
  93. package/templates/.agents/rules/strategy-pattern.en.md +25 -0
  94. package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
  95. package/templates/.agents/rules/task-short-id.en.md +18 -12
  96. package/templates/.agents/rules/task-short-id.zh-CN.md +12 -7
  97. package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
  98. package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
  99. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
  100. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
  101. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  102. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  103. package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
  104. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
  105. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  106. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  107. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-client.js +0 -130
  108. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +0 -127
  109. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-operation.js +0 -131
  110. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-registration.js +0 -275
  111. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.js +0 -106
  112. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +0 -345
  113. package/dist/lib/sandbox/control/agent-operation.js +0 -34
  114. package/lib/agent-clients/adapters/codex-lifecycle/controller-client.ts +0 -158
  115. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +0 -163
  116. package/lib/agent-clients/adapters/codex-lifecycle/controller-operation.ts +0 -151
  117. package/lib/agent-clients/adapters/codex-lifecycle/controller-registration.ts +0 -352
  118. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller-cli.ts +0 -109
  119. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +0 -437
  120. package/lib/sandbox/control/agent-operation.ts +0 -49
@@ -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,195 @@ 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 pathExists(candidate) {
219
+ try {
220
+ fs.lstatSync(candidate);
221
+ return true;
222
+ }
223
+ catch (error) {
224
+ if (error.code === 'ENOENT')
225
+ return false;
226
+ throw error;
227
+ }
228
+ }
229
+ function assertTaskControlRootPath(controlRoot, taskId, expectedProject) {
230
+ if (path.basename(controlRoot) !== taskId || path.basename(path.dirname(controlRoot)) !== 'tasks'
231
+ || (expectedProject !== undefined && path.basename(path.dirname(path.dirname(controlRoot))) !== expectedProject)) {
232
+ throw new Error('control root path does not match the task/project layout');
233
+ }
234
+ const managedBase = path.dirname(path.dirname(path.dirname(controlRoot)));
235
+ let current = managedBase;
236
+ for (const segment of path.relative(managedBase, controlRoot).split(path.sep).filter(Boolean)) {
237
+ const stat = fs.lstatSync(current);
238
+ if (stat.isSymbolicLink())
239
+ throw new Error('control root ancestor is a symbolic link');
240
+ current = path.join(current, segment);
241
+ }
242
+ const stat = fs.lstatSync(controlRoot);
243
+ if (!stat.isDirectory() || stat.isSymbolicLink())
244
+ throw new Error('control root is not a real directory');
245
+ }
246
+ function assertTaskControlManifestIdentity(controlRoot, taskId, repoRoot, expectedProject) {
247
+ const controlManifest = path.join(controlRoot, 'manifest.json');
248
+ if (!fs.existsSync(controlManifest))
249
+ throw new Error('control manifest is missing');
250
+ const manifestStat = fs.lstatSync(controlManifest);
251
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink())
252
+ throw new Error('control manifest is not a real file');
253
+ const manifest = JSON.parse(fs.readFileSync(controlManifest, 'utf8'));
254
+ const project = expectedProject ?? path.basename(path.dirname(path.dirname(controlRoot)));
255
+ if (manifest.mode !== 'task-bound' || manifest.taskId !== taskId || manifest.project !== project
256
+ || typeof manifest.repoRoot !== 'string' || fs.realpathSync.native(manifest.repoRoot) !== fs.realpathSync.native(repoRoot)) {
257
+ throw new Error('control manifest identity does not match the task');
258
+ }
259
+ }
260
+ function taskBoundControlMoveSafety(request, controlRoot, options, taskId, repoRoot, validateManifestIdentity) {
261
+ if (!controlRoot)
262
+ return null;
263
+ const controlManifest = path.join(controlRoot, 'manifest.json');
264
+ const controlLease = path.join(controlRoot, 'lease.json');
265
+ const stateExists = pathExists(controlRoot) || pathExists(controlManifest) || pathExists(controlLease);
266
+ if (stateExists) {
267
+ try {
268
+ assertTaskControlRootPath(controlRoot, taskId, options.controlProject);
269
+ if (validateManifestIdentity) {
270
+ assertTaskControlManifestIdentity(controlRoot, taskId, repoRoot, options.controlProject);
271
+ }
272
+ else {
273
+ const manifestStat = fs.lstatSync(controlManifest);
274
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink())
275
+ throw new Error('control manifest is not a real file');
276
+ }
277
+ }
278
+ catch {
279
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task-bound sandbox control root is missing, malformed, or belongs to another task' };
280
+ }
281
+ }
282
+ if (request.intent !== 'complete' && (pathExists(controlManifest) || pathExists(controlLease))) {
283
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory' };
284
+ }
285
+ return null;
286
+ }
287
+ function orchestrationMoveSafety(sourcePath) {
288
+ const orchestrationPath = path.join(sourcePath, '.runtime', 'orchestration.json');
289
+ if (!pathExists(orchestrationPath))
290
+ return null;
291
+ try {
292
+ const run = JSON.parse(fs.readFileSync(orchestrationPath, 'utf8'));
293
+ if (run.status === 'running' || run.pendingDelegation !== null && run.pendingDelegation !== undefined) {
294
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task orchestration has an active or pending delegation; settle it before moving the task directory' };
295
+ }
296
+ }
297
+ catch {
298
+ return { code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE', message: 'task orchestration state is unreadable; refusing to move the task directory' };
299
+ }
300
+ return null;
301
+ }
302
+ function runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, validateControlManifestIdentity) {
303
+ if (!movingTaskDirectory)
304
+ return null;
305
+ const controlSafety = taskBoundControlMoveSafety(request, options.controlRoot, options, taskId, repoRoot, validateControlManifestIdentity);
306
+ return controlSafety ?? orchestrationMoveSafety(sourcePath);
307
+ }
308
+ function targetContainsTask(targetPath, taskId, request) {
309
+ try {
310
+ const stat = fs.lstatSync(targetPath);
311
+ if (!stat.isDirectory() || stat.isSymbolicLink())
312
+ return false;
313
+ const entries = fs.readdirSync(targetPath);
314
+ if (entries.length === 0)
315
+ return true;
316
+ const taskPath = path.join(targetPath, 'task.md');
317
+ const journalPath = path.join(targetPath, '.task-lifecycle.json');
318
+ if (!fs.existsSync(taskPath) || !fs.existsSync(journalPath))
319
+ return false;
320
+ const frontmatter = parseTypedTaskFrontmatter(fs.readFileSync(taskPath, 'utf8'));
321
+ const journal = parseJournal(fs.readFileSync(journalPath, 'utf8'));
322
+ return frontmatter.id === taskId && frontmatter.status === 'completed'
323
+ && journal.taskId === taskId && journal.intent === request.intent
324
+ && journal.intentDigest === intentIdentity(request, taskId);
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ }
139
330
  function actionAndNote(request, restoredFiles = 0) {
140
331
  if (request.intent === 'block')
141
332
  return { action: 'Block Task', note: request.reason };
@@ -367,7 +558,7 @@ export function inspectTaskLifecycleProgress(repoRoot, taskId, agent) {
367
558
  || journal.intentDigest !== intentIdentity(request, taskId)
368
559
  || journal.sourceState !== 'active' || journal.targetState !== 'completed')
369
560
  return 'unknown';
370
- if (journal.completedSteps.some((step, index) => STEPS[index] !== step))
561
+ if (journal.completedSteps.some((step, index) => stepsForIntent(journal.intent)[index] !== step))
371
562
  return 'unknown';
372
563
  const directoryMoved = journal.completedSteps.includes('directory-moved');
373
564
  const registryCommitted = journal.completedSteps.includes('registry-committed');
@@ -375,7 +566,7 @@ export function inspectTaskLifecycleProgress(repoRoot, taskId, agent) {
375
566
  if (state === 'active' && (directoryMoved || !hasShortId))
376
567
  return 'unknown';
377
568
  if (state === 'completed' && (!journal.completedSteps.includes('task-written')
378
- || !matchingCompletion(content, request) || (registryCommitted && hasShortId)))
569
+ || !matchingCompletion(content, request) || registryCommitted || !hasShortId))
379
570
  return 'unknown';
380
571
  if (frontmatter.status !== state
381
572
  && !(state === 'active' && frontmatter.status === 'completed' && matchingCompletion(content, request))) {
@@ -411,7 +602,12 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
411
602
  }
412
603
  const spec = transition(request);
413
604
  const hot = locateHotTaskDirs(repoRoot, taskId);
414
- if (hot.length > 1)
605
+ const activeSource = hot.find((candidate) => candidate.state === 'active');
606
+ const completedTarget = hot.find((candidate) => candidate.state === 'completed');
607
+ const transferInProgress = request.intent === 'complete' && Boolean(activeSource && completedTarget)
608
+ && targetContainsTask(completedTarget.taskDir, taskId, request)
609
+ && fs.existsSync(path.join(activeSource.taskDir, '.task-lifecycle.json'));
610
+ if (hot.length > 1 && !transferInProgress)
415
611
  return failed(request, { code: 'LIFECYCLE_IDENTITY_CONFLICT', message: `task ${taskId} exists in multiple hot states` }, { taskId });
416
612
  let sourceState;
417
613
  let sourcePath;
@@ -424,6 +620,11 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
424
620
  sourcePath = validation.stagingDir;
425
621
  restoredFiles = validation.fileCount;
426
622
  }
623
+ else if (activeSource && request.intent === 'complete') {
624
+ sourceState = activeSource.state;
625
+ sourcePath = activeSource.taskDir;
626
+ restoredFiles = fs.readdirSync(sourcePath).filter((name) => !name.startsWith('.')).length;
627
+ }
427
628
  else if (hot[0]) {
428
629
  sourceState = hot[0].state;
429
630
  sourcePath = hot[0].taskDir;
@@ -433,38 +634,8 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
433
634
  return failed(request, { code: 'LIFECYCLE_TASK_NOT_FOUND', message: `task ${taskId} has no lifecycle source` }, { taskId });
434
635
  }
435
636
  const targetPath = path.join(repoRoot, '.agents', 'workspace', spec.target, taskId);
637
+ const requiredSteps = stepsForIntent(request.intent);
436
638
  const movingTaskDirectory = sourcePath !== targetPath;
437
- const assertRuntimeMoveSafe = () => {
438
- if (!movingTaskDirectory)
439
- return null;
440
- const controlManifest = path.join(sourcePath, '.runtime', 'sandbox-control', 'manifest.json');
441
- const controlLease = path.join(sourcePath, '.runtime', 'sandbox-control', 'lease.json');
442
- if (fs.existsSync(controlManifest) || fs.existsSync(controlLease)) {
443
- return {
444
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
445
- message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory'
446
- };
447
- }
448
- const orchestrationPath = path.join(sourcePath, '.runtime', 'orchestration.json');
449
- if (fs.existsSync(orchestrationPath)) {
450
- try {
451
- const run = JSON.parse(fs.readFileSync(orchestrationPath, 'utf8'));
452
- if (run.status === 'running' || run.pendingDelegation !== null && run.pendingDelegation !== undefined) {
453
- return {
454
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
455
- message: 'task orchestration has an active or pending delegation; settle it before moving the task directory'
456
- };
457
- }
458
- }
459
- catch {
460
- return {
461
- code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
462
- message: 'task orchestration state is unreadable; refusing to move the task directory'
463
- };
464
- }
465
- }
466
- return null;
467
- };
468
639
  const currentTaskFile = path.join(sourcePath, 'task.md');
469
640
  const existingJournal = fs.existsSync(path.join(sourcePath, '.task-lifecycle.json'));
470
641
  if (sourceState === spec.target && !existingJournal) {
@@ -474,7 +645,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
474
645
  return {
475
646
  ...failed(request, { code: '', message: '' }), status: 'no-op', error: null, taskId,
476
647
  sourceState, targetState: spec.target, sourcePath, targetPath, pendingSteps: [],
477
- completedSteps: STEPS, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
648
+ completedSteps: requiredSteps, timestamp: typeof frontmatter.updated_at === 'string' ? frontmatter.updated_at : null,
478
649
  agentInfraVersion: typeof frontmatter.agent_infra_version === 'string' ? frontmatter.agent_infra_version : null
479
650
  };
480
651
  }
@@ -483,10 +654,11 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
483
654
  if (!spec.sources.includes(sourceState) && !existingJournal) {
484
655
  return failed(request, { code: 'LIFECYCLE_SOURCE_INVALID', message: `${request.intent} is not allowed from ${sourceState}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
485
656
  }
486
- if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal) {
657
+ if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal
658
+ && !(request.intent === 'complete' && targetContainsTask(targetPath, taskId, request))) {
487
659
  return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
488
660
  }
489
- const moveSafety = assertRuntimeMoveSafe();
661
+ const moveSafety = runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, true);
490
662
  if (moveSafety)
491
663
  return failed(request, moveSafety, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
492
664
  const content = io.readFileSync(currentTaskFile);
@@ -515,7 +687,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
515
687
  }
516
688
  const registry = JSON.parse(registryRequest.output || '{"version":1,"ids":{}}');
517
689
  const registeredKey = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId)?.[0];
518
- if (spec.registry === 'release' && sourceState === 'active' && !registeredKey) {
690
+ if ((spec.registry === 'release' || request.intent === 'complete') && sourceState === 'active' && !registeredKey) {
519
691
  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
692
  }
521
693
  if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1) {
@@ -540,7 +712,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
540
712
  if (request.dryRun) {
541
713
  if (!journal.failure)
542
714
  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)) });
715
+ 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
716
  }
545
717
  if (journal.intentDigest !== digest)
546
718
  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 +763,8 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
591
763
  if (sourceState === spec.target && journal.sourceState !== spec.target)
592
764
  completed.add('directory-moved');
593
765
  const currentMappings = loadShortIdByTaskId(repoRoot);
594
- if ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId))) {
766
+ if (requiredSteps.includes('registry-committed')
767
+ && ((spec.target === 'active' && currentMappings.has(taskId)) || (spec.target !== 'active' && !currentMappings.has(taskId)))) {
595
768
  if (completed.has('directory-moved'))
596
769
  completed.add('registry-committed');
597
770
  }
@@ -603,7 +776,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
603
776
  task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
604
777
  shortId: { effect: 'unchanged', shortId: null, changed: false },
605
778
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
606
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
779
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
607
780
  };
608
781
  }
609
782
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles))
@@ -637,30 +810,42 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
637
810
  task: { operations: taskOperations }, directory: { effect: 'unchanged', changed: false },
638
811
  shortId: { effect: 'unchanged', shortId: null, changed: false },
639
812
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
640
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
813
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
641
814
  };
642
815
  }
643
- if (fs.existsSync(targetPath) && !fs.existsSync(sourcePath))
816
+ if (fs.existsSync(targetPath) && !fs.existsSync(path.join(sourcePath, 'task.md')))
644
817
  completed.add('directory-moved');
645
818
  if (!completed.has('directory-moved')) {
646
- const moveSafety = assertRuntimeMoveSafe();
819
+ const moveSafety = runtimeMoveSafety(request, movingTaskDirectory, options, taskId, repoRoot, sourcePath, false);
647
820
  if (moveSafety)
648
821
  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
822
  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')) {
823
+ if (request.intent === 'complete') {
824
+ try {
825
+ if (!fs.existsSync(targetPath))
826
+ fs.mkdirSync(targetPath, { recursive: false, mode: 0o700 });
827
+ if (!targetContainsTask(targetPath, taskId, request)) {
828
+ throw new Error('completed target has no matching task identity');
829
+ }
830
+ transferTaskContents(sourcePath, targetPath);
831
+ for (const name of fs.readdirSync(sourcePath).filter((entry) => entry !== '.task-lifecycle.json' && entry !== 'task.md').sort()) {
832
+ fs.rmSync(path.join(sourcePath, name), { recursive: true, force: false });
833
+ }
834
+ fs.rmSync(path.join(sourcePath, 'task.md'), { force: false });
835
+ fs.rmSync(path.join(sourcePath, '.task-lifecycle.json'), { force: false });
836
+ }
837
+ catch (error) {
838
+ const failure = { code: 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED', message: String(error) };
839
+ const sourceJournalPath = path.join(sourcePath, '.task-lifecycle.json');
840
+ rememberJournalFailure(sourceJournalPath, journal, io, failure);
841
+ return failed(request, failure, {
842
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath,
843
+ journalPath: sourceJournalPath, task: { operations: taskOperations },
844
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
845
+ pendingSteps: requiredSteps.filter((step) => !completed.has(step) || step === 'directory-moved'),
846
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
847
+ });
848
+ }
664
849
  completed.add('directory-moved');
665
850
  journal.completedSteps = [...completed];
666
851
  journalPath = path.join(targetPath, '.task-lifecycle.json');
@@ -668,12 +853,58 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
668
853
  writeJournal(journalPath, journal, io);
669
854
  }
670
855
  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 });
856
+ return failed(request, { code: 'LIFECYCLE_JOURNAL_WRITE_FAILED', message: String(error) }, {
857
+ taskId, sourceState, targetState: spec.target, sourcePath, targetPath, journalPath,
858
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
859
+ changed: true, completedSteps: [...completed].filter((step) => step !== 'directory-moved'),
860
+ pendingSteps: requiredSteps.filter((step) => step !== 'task-written'),
861
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
862
+ });
863
+ }
864
+ }
865
+ else {
866
+ if (fs.existsSync(targetPath))
867
+ 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 });
868
+ try {
869
+ (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath);
870
+ }
871
+ catch (error) {
872
+ const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) };
873
+ rememberJournalFailure(journalPath, journal, io, failure);
874
+ 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 });
672
875
  }
876
+ if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
877
+ journalPath = path.join(targetPath, '.task-lifecycle.json');
878
+ }
879
+ else if (!completed.has('directory-moved')) {
880
+ completed.add('directory-moved');
881
+ journal.completedSteps = [...completed];
882
+ journalPath = path.join(targetPath, '.task-lifecycle.json');
883
+ try {
884
+ writeJournal(journalPath, journal, io);
885
+ }
886
+ catch (error) {
887
+ 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 });
888
+ }
889
+ }
890
+ }
891
+ }
892
+ const activeTaskPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
893
+ if (request.intent === 'complete' && fs.existsSync(activeTaskPath)) {
894
+ try {
895
+ fs.rmdirSync(activeTaskPath);
896
+ }
897
+ catch (error) {
898
+ return failed(request, { code: 'LIFECYCLE_SOURCE_DIRECTORY_CLEANUP_FAILED', message: String(error) }, {
899
+ taskId, sourceState, targetState: spec.target, sourcePath: activeTaskPath, targetPath, journalPath,
900
+ task: { operations: taskOperations }, directory: { effect: 'move', changed: true },
901
+ changed: true, completedSteps: [...completed], pendingSteps: [],
902
+ timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion
903
+ });
673
904
  }
674
905
  }
675
- let shortId = { effect: 'unchanged', shortId: null, changed: false };
676
- if (!completed.has('registry-committed')) {
906
+ let shortId = { effect: 'unchanged', shortId: request.intent === 'complete' ? registeredKey ?? null : null, changed: false };
907
+ if (requiredSteps.includes('registry-committed') && !completed.has('registry-committed')) {
677
908
  try {
678
909
  shortId = mutateShortIdRegistry(repoRoot, taskId, spec.registry);
679
910
  }
@@ -692,7 +923,7 @@ function applyTaskLifecycleUnlocked(requestInput, options = {}) {
692
923
  const mappings = loadShortIdByTaskId(repoRoot);
693
924
  if (spec.target === 'active' && !mappings.has(taskId))
694
925
  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))
926
+ if (request.intent !== 'complete' && spec.target !== 'active' && mappings.has(taskId))
696
927
  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
928
  try {
698
929
  io.unlinkSync(journalPath);