@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.
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +11 -25
- package/dist/lib/internal/sandbox-control.js +1 -81
- package/dist/lib/internal/task-finalization.js +26 -19
- package/dist/lib/internal/task-lifecycle.js +1 -1
- package/dist/lib/platform/github-client.js +33 -3
- package/dist/lib/sandbox/commands/create.js +6 -2
- package/dist/lib/sandbox/commands/ls.js +4 -5
- package/dist/lib/sandbox/commands/rm.js +1 -1
- package/dist/lib/sandbox/control/client.js +4 -64
- package/dist/lib/sandbox/control/executor.js +4 -50
- package/dist/lib/sandbox/control/lifecycle.js +66 -4
- package/dist/lib/sandbox/control/protocol.js +5 -16
- package/dist/lib/sandbox/control/server.js +203 -234
- package/dist/lib/sandbox/control/task-view.js +0 -13
- package/dist/lib/sandbox/mounts.js +7 -0
- package/dist/lib/sandbox/recovery.js +28 -9
- package/dist/lib/sandbox/removal.js +19 -0
- package/dist/lib/sandbox/workspace-identity.js +18 -6
- package/dist/lib/sandbox/workspace-view.js +48 -2
- package/dist/lib/task/commands/ls.js +3 -3
- package/dist/lib/task/control-authority.js +2 -0
- package/dist/lib/task/control-recovery.js +1 -2
- package/dist/lib/task/finalization.js +14 -0
- package/dist/lib/task/lifecycle.js +203 -31
- package/dist/lib/task/orchestration.js +26 -72
- package/dist/lib/task/short-id.js +37 -27
- package/dist/lib/task/verification-engine.js +4 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +20 -23
- package/lib/internal/sandbox-control.ts +0 -97
- package/lib/internal/task-finalization.ts +30 -18
- package/lib/internal/task-lifecycle.ts +1 -1
- package/lib/platform/github-client.ts +34 -3
- package/lib/sandbox/commands/create.ts +6 -1
- package/lib/sandbox/commands/ls.ts +4 -5
- package/lib/sandbox/commands/rm.ts +1 -1
- package/lib/sandbox/control/client.ts +5 -78
- package/lib/sandbox/control/executor.ts +2 -53
- package/lib/sandbox/control/lifecycle.ts +67 -4
- package/lib/sandbox/control/protocol.ts +6 -20
- package/lib/sandbox/control/server.ts +9 -44
- package/lib/sandbox/control/task-view.ts +0 -19
- package/lib/sandbox/mounts.ts +8 -0
- package/lib/sandbox/recovery.ts +35 -16
- package/lib/sandbox/removal.ts +21 -2
- package/lib/sandbox/workspace-identity.ts +16 -6
- package/lib/sandbox/workspace-view.ts +41 -2
- package/lib/task/commands/ls.ts +3 -3
- package/lib/task/control-authority.ts +1 -0
- package/lib/task/control-recovery.ts +2 -3
- package/lib/task/finalization.ts +14 -0
- package/lib/task/lifecycle.ts +180 -27
- package/lib/task/orchestration.ts +30 -68
- package/lib/task/short-id.ts +33 -40
- package/lib/task/verification-engine.ts +3 -9
- package/package.json +1 -1
- package/templates/.agents/rules/README.en.md +1 -0
- package/templates/.agents/rules/README.zh-CN.md +1 -0
- package/templates/.agents/rules/strategy-pattern.en.md +25 -0
- package/templates/.agents/rules/strategy-pattern.zh-CN.md +25 -0
- package/templates/.agents/skills/code-task/reference/code-rules.en.md +4 -0
- package/templates/.agents/skills/code-task/reference/code-rules.zh-CN.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +5 -26
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -23
- package/templates/.agents/skills/plan-task/SKILL.en.md +7 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +7 -4
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
package/lib/sandbox/removal.ts
CHANGED
|
@@ -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
|
-
|
|
142
|
+
const taskId = cleanupTaskId(requestedRef, repoRoot);
|
|
143
|
+
if (taskId) {
|
|
134
144
|
let task: ReturnType<typeof resolveTaskWorkspace>;
|
|
135
145
|
try {
|
|
136
|
-
task = resolveTaskWorkspace(
|
|
146
|
+
task = resolveTaskWorkspace(taskId, repoRoot);
|
|
137
147
|
} catch (error) {
|
|
138
|
-
if (!(error instanceof Error && error.message === `Task not found: ${
|
|
139
|
-
const branch = options.resolveMissingTask?.(
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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;
|
package/lib/task/commands/ls.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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-
|
|
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', '
|
|
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
|
]);
|
package/lib/task/finalization.ts
CHANGED
|
@@ -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
|
|
package/lib/task/lifecycle.ts
CHANGED
|
@@ -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:
|
|
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) =>
|
|
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) ||
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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:
|
|
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:
|
|
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 (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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) {
|
|
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 {
|