@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
@@ -1073,11 +1073,25 @@ async function rmPurgeCore(
1073
1073
  const confirm = prompt.confirm ?? p.confirm;
1074
1074
  const isCancel = prompt.isCancel ?? p.isCancel;
1075
1075
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
1076
-
1076
+ const listed = fetchSandboxRows(
1077
+ engine,
1078
+ sandboxLabel(config),
1079
+ sandboxBranchLabel(config),
1080
+ { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1081
+ );
1082
+ const rows = [...listed.running, ...listed.nonRunning];
1077
1083
  const containers = runEngine(engine, 'docker', [
1078
1084
  'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1079
1085
  ]).split('\n').filter(Boolean);
1086
+ const completedTaskIds = [...new Set(rows
1087
+ .filter((row) => row.workspaceMode === 'task-bound' && row.taskId)
1088
+ .map((row) => row.taskId!))];
1080
1089
  for (const name of containers) runSafeEngine(engine, 'docker', ['rm', '-f', name]);
1090
+ if (!sandboxContainersRemoved(engine, containers)) {
1091
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${containers.join(', ')}`);
1092
+ }
1093
+
1094
+ let cleanupCompleted = true;
1081
1095
 
1082
1096
  const worktrees = fs.existsSync(config.worktreeBase)
1083
1097
  ? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
@@ -1087,21 +1101,26 @@ async function rmPurgeCore(
1087
1101
  if (!isCancel(selected) && selected) {
1088
1102
  for (const worktree of worktrees) fs.rmSync(worktree, { recursive: true, force: true });
1089
1103
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1090
- }
1104
+ } else cleanupCompleted = false;
1091
1105
  }
1092
1106
 
1093
1107
  for (const dir of projectToolDirs(config, tools)) fs.rmSync(dir, { recursive: true, force: true });
1094
1108
  if (fs.existsSync(config.shellConfigBase)) {
1095
1109
  const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
1096
1110
  if (!isCancel(selected) && selected) fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
1111
+ else cleanupCompleted = false;
1097
1112
  }
1098
1113
  if (fs.existsSync(config.shareBase)) {
1099
1114
  const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
1100
1115
  if (!isCancel(selected) && selected) fs.rmSync(config.shareBase, { recursive: true, force: true });
1116
+ else cleanupCompleted = false;
1101
1117
  }
1102
1118
  for (const base of [config.workspaceViewBase, config.controlBase]) {
1103
1119
  fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
1104
1120
  }
1121
+ if (cleanupCompleted) {
1122
+ for (const taskId of completedTaskIds) releaseStaleShortIdRegistry(config.repoRoot, taskId);
1123
+ }
1105
1124
  const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
1106
1125
  if (!isCancel(removeImage) && removeImage) runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1107
1126
  p.outro(pc.green('All project sandboxes removed'));
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isRemovedHashShortIdInput } from '../task/short-id.ts';
4
+ import { resolveTaskRef } from '../task/resolve-ref.ts';
4
5
  import {
5
6
  resolveTaskWorkspace,
6
7
  type TaskWorkspaceState
@@ -76,6 +77,14 @@ function taskIdentity(repoRoot: string, taskId: string, registry: Record<string,
76
77
  return { mode: 'task-bound', taskId, shortId: shortIds[0]! };
77
78
  }
78
79
 
80
+ function cleanupTaskId(requestedRef: string, repoRoot: string): string | null {
81
+ if (TASK_ID_RE.test(requestedRef)) return requestedRef;
82
+ if (!SHORT_ID_RE.test(requestedRef)) return null;
83
+ const task = resolveTaskRef(requestedRef, { repoRoot });
84
+ if (!task.ok) throw new Error(task.message);
85
+ return task.taskId;
86
+ }
87
+
79
88
  export function resolveSandboxTarget(requestedRef: string, repoRoot: string): SandboxTarget {
80
89
  if (isRemovedHashShortIdInput(requestedRef)) {
81
90
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
@@ -130,23 +139,24 @@ export function resolveSandboxCleanupTarget(
130
139
  throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
131
140
  }
132
141
 
133
- if (TASK_ID_RE.test(requestedRef)) {
142
+ const taskId = cleanupTaskId(requestedRef, repoRoot);
143
+ if (taskId) {
134
144
  let task: ReturnType<typeof resolveTaskWorkspace>;
135
145
  try {
136
- task = resolveTaskWorkspace(requestedRef, repoRoot);
146
+ task = resolveTaskWorkspace(taskId, repoRoot);
137
147
  } catch (error) {
138
- if (!(error instanceof Error && error.message === `Task not found: ${requestedRef}`)) throw error;
139
- const branch = options.resolveMissingTask?.(requestedRef);
148
+ if (!(error instanceof Error && error.message === `Task not found: ${taskId}`)) throw error;
149
+ const branch = options.resolveMissingTask?.(taskId);
140
150
  if (!branch) throw error;
141
151
  return {
142
152
  requestedRef,
143
153
  branch,
144
- workspace: { mode: 'task-bound', taskId: requestedRef },
154
+ workspace: { mode: 'task-bound', taskId },
145
155
  taskState: 'unknown'
146
156
  };
147
157
  }
148
158
  if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
149
- throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
159
+ throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${taskId} is ${task.state}`);
150
160
  }
151
161
  return {
152
162
  requestedRef,
@@ -157,13 +157,15 @@ export function assertSandboxTaskSource(repoRoot: string, taskId: string): strin
157
157
  return canonical;
158
158
  }
159
159
 
160
- export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
160
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string, taskId?: string): void {
161
161
  const resolvedWorktreeRoot = path.resolve(worktreeRoot);
162
162
  const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
163
163
  const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
164
164
  const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
165
165
  const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
166
- for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
166
+ const completedTaskTarget = taskId ? path.join(workspaceRoot, 'completed', taskId) : null;
167
+ if (taskId && !/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
168
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget, ...(completedTaskTarget ? [completedTaskTarget] : [])]) {
167
169
  assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
168
170
  }
169
171
  fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
@@ -171,10 +173,47 @@ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void
171
173
  fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
172
174
  fs.chmodSync(hostPath, 0o700);
173
175
  }
176
+ if (completedTaskTarget) fs.mkdirSync(completedTaskTarget, { recursive: true, mode: 0o700 });
174
177
  fs.chmodSync(workspaceRoot, 0o700);
175
178
  fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
176
179
  }
177
180
 
181
+ export function prepareSandboxCompletedTaskMountSource(repoRoot: string, taskId: string): string {
182
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
183
+ const workspaceRoot = path.resolve(repoRoot, '.agents', 'workspace');
184
+ fs.mkdirSync(path.join(workspaceRoot, 'completed'), { recursive: true, mode: 0o700 });
185
+ const completedRoot = path.join(workspaceRoot, 'completed');
186
+ const completedStat = fs.lstatSync(completedRoot);
187
+ if (!completedStat.isDirectory() || completedStat.isSymbolicLink()) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
188
+ const target = path.join(completedRoot, taskId);
189
+ try {
190
+ const stat = fs.lstatSync(target);
191
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(target).length > 0) {
192
+ throw new Error(`SANDBOX_COMPLETED_MOUNT_CONFLICT: ${target}`);
193
+ }
194
+ } catch (error) {
195
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
196
+ fs.mkdirSync(target, { mode: 0o700 });
197
+ }
198
+ return sandboxCompletedTaskMountSource(repoRoot, taskId);
199
+ }
200
+
201
+ export function sandboxCompletedTaskMountSource(repoRoot: string, taskId: string): string {
202
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
203
+ const source = path.resolve(repoRoot, '.agents', 'workspace', 'completed', taskId);
204
+ const stat = fs.lstatSync(source);
205
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
206
+ const canonicalWorkspace = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace'));
207
+ const canonicalSource = fs.realpathSync.native(source);
208
+ const canonicalRepoRoot = fs.realpathSync.native(repoRoot);
209
+ const workspaceRelative = path.relative(canonicalRepoRoot, canonicalWorkspace);
210
+ const relative = path.relative(canonicalWorkspace, canonicalSource);
211
+ if (workspaceRelative.startsWith('..') || path.isAbsolute(workspaceRelative)
212
+ || path.dirname(canonicalSource) !== fs.realpathSync.native(path.dirname(source))
213
+ || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
214
+ return canonicalSource;
215
+ }
216
+
178
217
  export function materializeSandboxWorkspaceView(params: Readonly<{
179
218
  base: string;
180
219
  project: string;
@@ -75,9 +75,9 @@ type TaskRow = {
75
75
  function collectTasks(repoRoot: string, state: 'active' | 'blocked' | 'completed'): TaskRow[] {
76
76
  const dir = path.join(repoRoot, '.agents', 'workspace', state);
77
77
  if (!fs.existsSync(dir)) return [];
78
- // Short ids live only in the registry and only for active tasks; archived
79
- // (blocked/completed) tasks have released their short id and render '-'.
80
- const shortIdByTaskId = state === 'active' ? loadShortIdByTaskId(repoRoot) : new Map<string, string>();
78
+ const shortIdByTaskId = state === 'active' || state === 'completed'
79
+ ? loadShortIdByTaskId(repoRoot)
80
+ : new Map<string, string>();
81
81
  const rows: TaskRow[] = [];
82
82
  for (const entry of fs.readdirSync(dir).sort()) {
83
83
  if (!TASK_ID_RE.test(entry)) continue;
@@ -477,6 +477,7 @@ export function parseTaskControlOperation(
477
477
  const taskRef = args[0]!;
478
478
  const intent = args[1]!;
479
479
  if (family === 'task-lifecycle') {
480
+ if (intent === 'complete') operationInvalid('complete must use task-finalization');
480
481
  const values = parseValues(args, 2, LIFECYCLE_FLAGS);
481
482
  const agent = normalizeAgentToken(value(values, '--agent') ?? '');
482
483
  if (!agent) operationInvalid('lifecycle agent is invalid');
@@ -13,7 +13,7 @@ export function parseControlOutput(output: string | null): Record<string, unknow
13
13
  }
14
14
 
15
15
  export type ControlRecoveryOperation = Readonly<{
16
- family: 'task-lifecycle' | 'task-finalization' | 'task-create' | 'agent-client';
16
+ family: 'task-lifecycle' | 'task-create' | 'agent-client';
17
17
  intent: string;
18
18
  class: string;
19
19
  }>;
@@ -51,10 +51,9 @@ export type ControlRecoveryDecision = Readonly<{
51
51
  reasonCode: string;
52
52
  }>;
53
53
 
54
- const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'] as const;
54
+ const LIFECYCLE_INTENTS = ['block', 'activate', 'cancel', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'] as const;
55
55
  export const SANDBOX_CONTROL_RECOVERY_OPERATIONS: readonly ControlRecoveryOperation[] = Object.freeze([
56
56
  ...LIFECYCLE_INTENTS.map((intent) => ({ family: 'task-lifecycle' as const, intent, class: 'lifecycle-mutation' })),
57
- { family: 'task-finalization', intent: 'complete', class: 'finalization' },
58
57
  { family: 'task-create', intent: 'create', class: 'task-create' },
59
58
  ...(['controller.close', 'controller.verify'] as const).map((intent) => ({ family: 'agent-client' as const, intent, class: 'agent-client-operation' }))
60
59
  ]);
@@ -79,6 +79,7 @@ type TaskFinalizationOptions = Readonly<{
79
79
  issueInspect?: typeof inspectPlatformIssue;
80
80
  verify?: typeof verifyTaskEvent;
81
81
  preflight?: typeof verifyTaskEvent;
82
+ beforeLifecycle?: (taskId: string) => Promise<void>;
82
83
  }>;
83
84
 
84
85
  type TaskFinalizationReceipt = Readonly<{
@@ -1303,6 +1304,19 @@ async function commitPreparedTaskFinalization(request: TaskFinalizationRequest,
1303
1304
  async function applyTaskFinalization(request: TaskFinalizationRequest, options: TaskFinalizationOptions): Promise<TaskFinalizationResult> {
1304
1305
  const prepared = await prepareTaskFinalization(request, options);
1305
1306
  if (prepared.status !== 'prepared') return prepared;
1307
+ if (options.beforeLifecycle) {
1308
+ try {
1309
+ await options.beforeLifecycle(prepared.taskId!);
1310
+ } catch (error) {
1311
+ const detail = errorOf(error, 'TASK_FINALIZATION_PRE_LIFECYCLE_FAILED', true);
1312
+ return failed(prepared.taskId, detail, {
1313
+ changed: prepared.changed,
1314
+ completedSteps: prepared.completedSteps,
1315
+ pendingSteps: prepared.pendingSteps,
1316
+ warnings: prepared.warnings
1317
+ });
1318
+ }
1319
+ }
1306
1320
  return commitPreparedTaskFinalization(request, options);
1307
1321
  }
1308
1322
 
@@ -24,6 +24,7 @@ const lifecycleIntentCatalog = [
24
24
  'block', 'activate', 'cancel', 'complete', 'close-codescan', 'close-dependabot', 'restore', 'recover-started'
25
25
  ] as const;
26
26
  const lifecycleFailureCatalog = [
27
+ 'LIFECYCLE_DIRECTORY_TRANSFER_FAILED',
27
28
  'LIFECYCLE_DIRECTORY_RENAME_FAILED',
28
29
  'LIFECYCLE_DOCUMENT_INVALID',
29
30
  'LIFECYCLE_FINAL_STATE_INVALID',
@@ -117,6 +118,9 @@ type TaskLifecycleOptions = {
117
118
  type TaskCompletionProjection = Readonly<{ taskId: string; content: string; timestamp: string; agentInfraVersion: string }>;
118
119
 
119
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
+ }
120
124
  const DEFAULT_IO: LifecycleFileSystem = {
121
125
  renameSync: (source, target) => fs.renameSync(source, target),
122
126
  readFileSync: (file) => fs.readFileSync(file, 'utf8'),
@@ -130,7 +134,7 @@ function failed(request: Pick<TaskLifecycleRequest, 'taskRef' | 'intent'>, error
130
134
  intent: request.intent, sourceState: null, targetState: null, sourcePath: null,
131
135
  targetPath: null, task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
132
136
  shortId: { effect: 'unchanged', shortId: null, changed: false }, timestamp: null,
133
- agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: STEPS,
137
+ agentInfraVersion: null, journalPath: null, completedSteps: [], pendingSteps: stepsForIntent(request.intent),
134
138
  error, ...extra
135
139
  };
136
140
  }
@@ -201,9 +205,96 @@ function transition(request: TaskLifecycleRequest): { sources: readonly SourceSt
201
205
  if (request.intent === 'activate') return { sources: ['blocked'], target: 'active', registry: 'alloc' };
202
206
  if (request.intent === 'cancel') return { sources: ['active', 'blocked'], target: 'completed', registry: 'release' };
203
207
  if (request.intent === 'restore') return { sources: ['staging'], target: 'active', registry: 'alloc' };
208
+ if (request.intent === 'complete') return { sources: ['active'], target: 'completed', registry: 'none' };
204
209
  return { sources: ['active'], target: 'completed', registry: 'release' };
205
210
  }
206
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
+
207
298
  function actionAndNote(request: TaskLifecycleRequest, restoredFiles = 0): { action: string; note: string } {
208
299
  if (request.intent === 'block') return { action: 'Block Task', note: request.reason };
209
300
  if (request.intent === 'activate') return { action: 'Activate Task', note: request.note };
@@ -401,13 +492,13 @@ export function inspectTaskLifecycleProgress(
401
492
  if (journal.taskId !== taskId || journal.intent !== 'complete'
402
493
  || journal.intentDigest !== intentIdentity(request, taskId)
403
494
  || journal.sourceState !== 'active' || journal.targetState !== 'completed') return 'unknown';
404
- 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';
405
496
  const directoryMoved = journal.completedSteps.includes('directory-moved');
406
497
  const registryCommitted = journal.completedSteps.includes('registry-committed');
407
498
  const hasShortId = loadShortIdByTaskId(repoRoot).has(taskId);
408
499
  if (state === 'active' && (directoryMoved || !hasShortId)) return 'unknown';
409
500
  if (state === 'completed' && (!journal.completedSteps.includes('task-written')
410
- || !matchingCompletion(content, request) || (registryCommitted && hasShortId))) return 'unknown';
501
+ || !matchingCompletion(content, request) || registryCommitted || !hasShortId)) return 'unknown';
411
502
  if (frontmatter.status !== state
412
503
  && !(state === 'active' && frontmatter.status === 'completed' && matchingCompletion(content, request))) {
413
504
  return 'unknown';
@@ -438,7 +529,12 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
438
529
  }
439
530
  const spec = transition(request);
440
531
  const hot = locateHotTaskDirs(repoRoot, taskId);
441
- 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 });
442
538
  let sourceState: SourceState;
443
539
  let sourcePath: string;
444
540
  let restoredFiles = 0;
@@ -446,6 +542,9 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
446
542
  const validation = validateRestoreStaging(request, repoRoot, taskId);
447
543
  if ('code' in validation) return failed(request, validation, { taskId });
448
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;
449
548
  } else if (hot[0]) {
450
549
  sourceState = hot[0].state; sourcePath = hot[0].taskDir;
451
550
  restoredFiles = fs.readdirSync(sourcePath).filter((name) => !name.startsWith('.')).length;
@@ -453,12 +552,13 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
453
552
  return failed(request, { code: 'LIFECYCLE_TASK_NOT_FOUND', message: `task ${taskId} has no lifecycle source` }, { taskId });
454
553
  }
455
554
  const targetPath = path.join(repoRoot, '.agents', 'workspace', spec.target, taskId);
555
+ const requiredSteps = stepsForIntent(request.intent);
456
556
  const movingTaskDirectory = sourcePath !== targetPath;
457
557
  const assertRuntimeMoveSafe = (): LifecycleError | null => {
458
558
  if (!movingTaskDirectory) return null;
459
559
  const controlManifest = path.join(sourcePath, '.runtime', 'sandbox-control', 'manifest.json');
460
560
  const controlLease = path.join(sourcePath, '.runtime', 'sandbox-control', 'lease.json');
461
- if (fs.existsSync(controlManifest) || fs.existsSync(controlLease)) {
561
+ if (request.intent !== 'complete' && (fs.existsSync(controlManifest) || fs.existsSync(controlLease))) {
462
562
  return {
463
563
  code: 'LIFECYCLE_RUNTIME_MOVE_UNSAFE',
464
564
  message: 'task-bound sandbox control state exists; stop and remove the verified sandbox before moving the task directory'
@@ -492,7 +592,7 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
492
592
  return {
493
593
  ...failed(request, { code: '', message: '' }), status: 'no-op', error: null, taskId,
494
594
  sourceState, targetState: spec.target, sourcePath, targetPath, pendingSteps: [],
495
- 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,
496
596
  agentInfraVersion: typeof frontmatter.agent_infra_version === 'string' ? frontmatter.agent_infra_version : null
497
597
  };
498
598
  }
@@ -501,7 +601,8 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
501
601
  if (!spec.sources.includes(sourceState) && !existingJournal) {
502
602
  return failed(request, { code: 'LIFECYCLE_SOURCE_INVALID', message: `${request.intent} is not allowed from ${sourceState}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
503
603
  }
504
- if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal) {
604
+ if (sourcePath !== targetPath && fs.existsSync(targetPath) && !existingJournal
605
+ && !(request.intent === 'complete' && targetContainsTask(targetPath, taskId, request))) {
505
606
  return failed(request, { code: 'LIFECYCLE_TARGET_CONFLICT', message: `target directory already exists: ${targetPath}` }, { taskId, sourceState, targetState: spec.target, sourcePath, targetPath });
506
607
  }
507
608
  const moveSafety = assertRuntimeMoveSafe();
@@ -525,7 +626,7 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
525
626
  }
526
627
  const registry = JSON.parse(registryRequest.output || '{"version":1,"ids":{}}') as { ids: Record<string, string> };
527
628
  const registeredKey = Object.entries(registry.ids).find(([, candidate]) => candidate === taskId)?.[0];
528
- if (spec.registry === 'release' && sourceState === 'active' && !registeredKey) {
629
+ if ((spec.registry === 'release' || request.intent === 'complete') && sourceState === 'active' && !registeredKey) {
529
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 });
530
631
  }
531
632
  if (spec.registry === 'alloc' && !registeredKey && Object.keys(registry.ids).length >= 10 ** configuredShortIdLength(repoRoot) - 1) {
@@ -544,7 +645,7 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
544
645
  }
545
646
  if (request.dryRun) {
546
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 });
547
- 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)) });
548
649
  }
549
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 });
550
651
  } else {
@@ -585,7 +686,8 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
585
686
  const completed = new Set(journal.completedSteps);
586
687
  if (sourceState === spec.target && journal.sourceState !== spec.target) completed.add('directory-moved');
587
688
  const currentMappings = loadShortIdByTaskId(repoRoot);
588
- 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)))) {
589
691
  if (completed.has('directory-moved')) completed.add('registry-committed');
590
692
  }
591
693
  let taskOperations: readonly TaskOperationSummary[] = [];
@@ -596,7 +698,7 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
596
698
  task: { operations: [] }, directory: { effect: 'unchanged', changed: false },
597
699
  shortId: { effect: 'unchanged', shortId: null, changed: false },
598
700
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
599
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
701
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
600
702
  };
601
703
  }
602
704
  if (matchingCompletion(io.readFileSync(path.join(sourcePath, 'task.md')), request, restoredFiles)) completed.add('task-written');
@@ -625,31 +727,82 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
625
727
  task: { operations: taskOperations }, directory: { effect: 'unchanged', changed: false },
626
728
  shortId: { effect: 'unchanged', shortId: null, changed: false },
627
729
  timestamp: journal.metadata.timestamp, agentInfraVersion: journal.metadata.agentInfraVersion,
628
- journalPath, completedSteps: [...completed], pendingSteps: STEPS.filter((step) => !completed.has(step))
730
+ journalPath, completedSteps: [...completed], pendingSteps: requiredSteps.filter((step) => !completed.has(step))
629
731
  };
630
732
  }
631
- 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');
632
734
  if (!completed.has('directory-moved')) {
633
735
  const moveSafety = assertRuntimeMoveSafe();
634
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 });
635
737
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
636
- 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 });
637
- try { (options.directoryRenameSync ?? io.renameSync)(sourcePath, targetPath); }
638
- catch (error) {
639
- const failure = { code: 'LIFECYCLE_DIRECTORY_RENAME_FAILED', message: String(error) } as const;
640
- rememberJournalFailure(journalPath, journal, io, failure);
641
- 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 });
642
- }
643
- if (completed.has('directory-moved') && !fs.existsSync(sourcePath)) {
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 });
747
+ }
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
+ });
761
+ }
762
+ completed.add('directory-moved');
763
+ journal.completedSteps = [...completed];
644
764
  journalPath = path.join(targetPath, '.task-lifecycle.json');
645
- } else if (!completed.has('directory-moved')) {
646
- completed.add('directory-moved'); journal.completedSteps = [...completed]; journalPath = path.join(targetPath, '.task-lifecycle.json');
647
765
  try { writeJournal(journalPath, journal, io); }
648
- 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
+ }
790
+ }
791
+ }
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
+ });
649
802
  }
650
803
  }
651
- let shortId: TaskLifecycleResult['shortId'] = { effect: 'unchanged', shortId: null, changed: false };
652
- 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')) {
653
806
  try { shortId = mutateShortIdRegistry(repoRoot, taskId, spec.registry); }
654
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 }); }
655
808
  completed.add('registry-committed'); journal.completedSteps = [...completed];
@@ -658,7 +811,7 @@ function applyTaskLifecycleUnlocked(requestInput: TaskLifecycleRequest, options:
658
811
  }
659
812
  const mappings = loadShortIdByTaskId(repoRoot);
660
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 });
661
- 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 });
662
815
  try { io.unlinkSync(journalPath); }
663
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 }); }
664
817
  return {