@fitlab-ai/agent-infra 0.9.8 → 0.9.9
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/bin/internal-cli.js +6 -1
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +58 -21
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +8 -1
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +173 -10
- package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +147 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +56 -67
- package/dist/lib/git/workflow.js +10 -1
- package/dist/lib/internal/codex-lifecycle.js +19 -5
- package/dist/lib/internal/codex-sandbox-controller.js +6 -13
- package/dist/lib/internal/git-workflow.js +5 -7
- package/dist/lib/internal/platform-pr.js +20 -7
- package/dist/lib/internal/release-workflow.js +2 -1
- package/dist/lib/internal/sandbox-control.js +79 -2
- package/dist/lib/internal/task-finalization.js +54 -0
- package/dist/lib/internal/task-orchestration.js +156 -291
- package/dist/lib/platform/pr-summary.js +48 -10
- package/dist/lib/platform/pull-requests.js +205 -41
- package/dist/lib/sandbox/commands/rm.js +316 -55
- package/dist/lib/sandbox/control/client.js +190 -6
- package/dist/lib/sandbox/control/controller-registration.js +273 -0
- package/dist/lib/sandbox/control/executor.js +137 -5
- package/dist/lib/sandbox/control/lifecycle.js +3 -1
- package/dist/lib/sandbox/control/protocol.js +67 -6
- package/dist/lib/sandbox/index.js +5 -3
- package/dist/lib/sandbox/managed-fs.js +5 -0
- package/dist/lib/sandbox/task-resolver.js +50 -6
- package/dist/lib/sandbox/workspace-identity.js +27 -0
- package/dist/lib/sandbox/worktree-safety.js +215 -2
- package/dist/lib/task/codex-orchestration.js +158 -33
- package/dist/lib/task/commands/status.js +59 -1
- package/dist/lib/task/commit-operation.js +335 -0
- package/dist/lib/task/commit-policy.js +18 -0
- package/dist/lib/task/delegation-receipts.js +243 -2
- package/dist/lib/task/finalization.js +567 -0
- package/dist/lib/task/operation-outcome.js +33 -0
- package/dist/lib/task/orchestration.js +187 -761
- package/dist/lib/task/short-id.js +59 -1
- package/dist/lib/task/task-execution-lock.js +6 -1
- package/dist/lib/task/verification-engine.js +113 -60
- package/dist/lib/task/verification.js +2 -1
- package/dist/lib/task/workflow-warning-intents.js +50 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +61 -19
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +12 -0
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +276 -12
- package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +180 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +83 -91
- package/lib/git/workflow.ts +11 -2
- package/lib/internal/codex-lifecycle.ts +18 -5
- package/lib/internal/codex-sandbox-controller.ts +6 -12
- package/lib/internal/git-workflow.ts +5 -6
- package/lib/internal/platform-pr.ts +14 -7
- package/lib/internal/release-workflow.ts +2 -1
- package/lib/internal/sandbox-control.ts +92 -2
- package/lib/internal/task-finalization.ts +68 -0
- package/lib/internal/task-orchestration.ts +20 -103
- package/lib/platform/pr-summary.ts +55 -11
- package/lib/platform/pull-requests.ts +211 -41
- package/lib/sandbox/commands/rm.ts +413 -61
- package/lib/sandbox/control/client.ts +255 -6
- package/lib/sandbox/control/controller-registration.ts +347 -0
- package/lib/sandbox/control/executor.ts +163 -5
- package/lib/sandbox/control/lifecycle.ts +2 -1
- package/lib/sandbox/control/protocol.ts +80 -7
- package/lib/sandbox/index.ts +5 -3
- package/lib/sandbox/managed-fs.ts +5 -0
- package/lib/sandbox/task-resolver.ts +58 -7
- package/lib/sandbox/workspace-identity.ts +50 -2
- package/lib/sandbox/workspace-view.ts +3 -3
- package/lib/sandbox/worktree-safety.ts +236 -2
- package/lib/task/codex-orchestration.ts +167 -33
- package/lib/task/commands/status.ts +78 -1
- package/lib/task/commit-operation.ts +430 -0
- package/lib/task/commit-policy.ts +30 -0
- package/lib/task/delegation-receipts.ts +258 -1
- package/lib/task/finalization.ts +719 -0
- package/lib/task/operation-outcome.ts +48 -0
- package/lib/task/orchestration.ts +217 -982
- package/lib/task/short-id.ts +65 -1
- package/lib/task/task-execution-lock.ts +10 -1
- package/lib/task/verification-engine.ts +118 -63
- package/lib/task/verification.ts +3 -2
- package/lib/task/workflow-warning-intents.ts +57 -2
- package/package.json +2 -2
- package/templates/.agents/QUICKSTART.en.md +2 -2
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/hooks/lifecycle-delegation.js +1 -21
- package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/commit/SKILL.en.md +41 -125
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
- package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
- package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
- package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
- package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
- package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.en.md +5 -5
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
- package/templates/.codex/hooks.json +4 -4
- package/dist/lib/task/commit-finalization.js +0 -305
- package/dist/lib/task/commit-intent.js +0 -165
- package/lib/task/commit-finalization.ts +0 -419
- package/lib/task/commit-intent.ts +0 -230
|
@@ -82,7 +82,7 @@ function readStartupOwner(filePath) {
|
|
|
82
82
|
async function waitForStartupTransition(root, timeoutMs, identityProbe = getProcessIdentityState) {
|
|
83
83
|
const transitionPath = path.join(root, BROKER_STARTING_FILE);
|
|
84
84
|
const deadline = Date.now() + timeoutMs;
|
|
85
|
-
while (
|
|
85
|
+
while (true) {
|
|
86
86
|
const transition = readStartupOwner(transitionPath);
|
|
87
87
|
if (!fs.existsSync(transitionPath))
|
|
88
88
|
return;
|
|
@@ -96,6 +96,8 @@ async function waitForStartupTransition(root, timeoutMs, identityProbe = getProc
|
|
|
96
96
|
}
|
|
97
97
|
continue;
|
|
98
98
|
}
|
|
99
|
+
if (Date.now() >= deadline)
|
|
100
|
+
break;
|
|
99
101
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
100
102
|
}
|
|
101
103
|
throw new Error('SANDBOX_CONTROL_BROKER_START_TRANSITION');
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { validateTaskCreateCandidate } from '../../task/create.js';
|
|
2
|
+
import { normalizeAgentToken } from '../../agent-clients/tokens.js';
|
|
2
3
|
export const SANDBOX_CONTROL_MAX_BYTES = 64 * 1024;
|
|
3
4
|
export const SANDBOX_CONTROL_ADMISSION_WINDOW_MS = 2_000;
|
|
4
5
|
export const SANDBOX_CONTROL_STATUS_INTERVAL_MS = 250;
|
|
5
6
|
export const SANDBOX_CONTROL_STATUS_STALE_MS = 1_500;
|
|
6
7
|
export const SANDBOX_CONTROL_FUTURE_SKEW_MS = 1_000;
|
|
7
|
-
export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-create'];
|
|
8
|
+
export const SANDBOX_CONTROL_FAMILIES = ['task-lifecycle', 'task-orchestration', 'task-finalization', 'task-create', 'codex-controller'];
|
|
8
9
|
export const DEFAULT_SANDBOX_CONTROL_TIMING = Object.freeze({
|
|
9
10
|
controlTickMs: 250,
|
|
10
11
|
parkedBindingInitialMs: 1_000,
|
|
@@ -33,7 +34,7 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
|
|
|
33
34
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request must be an object');
|
|
34
35
|
}
|
|
35
36
|
const request = value;
|
|
36
|
-
if (request.version !==
|
|
37
|
+
if (request.version !== 3 || typeof request.id !== 'string'
|
|
37
38
|
|| !/^[a-f0-9-]{16,64}$/.test(request.id) || request.token !== manifest.token
|
|
38
39
|
|| typeof request.family !== 'string' || !isSandboxControlFamily(request.family)
|
|
39
40
|
|| !Number.isSafeInteger(request.issuedAt) || !Number.isSafeInteger(request.expiresAt))
|
|
@@ -53,13 +54,47 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
|
|
|
53
54
|
fail('SANDBOX_CONTROL_REQUEST_TOO_LARGE', 'request exceeds the control limit');
|
|
54
55
|
}
|
|
55
56
|
if (request.family === 'task-create') {
|
|
56
|
-
const expected = ['candidate', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
|
|
57
|
+
const expected = ['candidate', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
|
|
57
58
|
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')) {
|
|
58
59
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
|
|
59
60
|
}
|
|
61
|
+
if (request.controllerProcess !== null || request.controllerProof !== null) {
|
|
62
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-create cannot carry controller authority');
|
|
63
|
+
}
|
|
60
64
|
return { ...request, candidate: validateTaskCreateCandidate(request.candidate) };
|
|
61
65
|
}
|
|
62
|
-
|
|
66
|
+
if (request.family === 'codex-controller') {
|
|
67
|
+
const expected = ['args', 'command', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
|
|
68
|
+
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
|
|
69
|
+
|| !Array.isArray(request.args) || request.args.length !== 0
|
|
70
|
+
|| !['open', 'close', 'verify'].includes(request.command)
|
|
71
|
+
|| !validControllerProcess(request.controllerProcess)
|
|
72
|
+
|| (request.command === 'open' && request.controllerProof !== null)
|
|
73
|
+
|| (request.command !== 'open' && !validControllerProof(request.controllerProof))
|
|
74
|
+
|| (request.command === 'verify'
|
|
75
|
+
&& JSON.stringify(request.controllerProcess) !== JSON.stringify(request.controllerProof.controllerProcess))) {
|
|
76
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller request schema is invalid');
|
|
77
|
+
}
|
|
78
|
+
if (manifest.mode !== 'task-bound' || !manifest.taskId) {
|
|
79
|
+
fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot register a Codex controller');
|
|
80
|
+
}
|
|
81
|
+
return request;
|
|
82
|
+
}
|
|
83
|
+
if (request.family === 'task-finalization') {
|
|
84
|
+
const expected = ['agent', 'args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'operation', 'token', 'version'];
|
|
85
|
+
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
|
|
86
|
+
|| request.operation !== 'complete'
|
|
87
|
+
|| !Array.isArray(request.args) || request.args.length !== 0
|
|
88
|
+
|| typeof request.agent !== 'string' || normalizeAgentToken(request.agent) !== request.agent
|
|
89
|
+
|| request.controllerProcess !== null || request.controllerProof !== null) {
|
|
90
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'task-finalization request schema or authorization is invalid');
|
|
91
|
+
}
|
|
92
|
+
if (manifest.mode !== 'task-bound' || !manifest.taskId) {
|
|
93
|
+
fail('SANDBOX_CONTROL_BRANCH_ONLY', 'branch-only sandboxes cannot finalize tasks');
|
|
94
|
+
}
|
|
95
|
+
return request;
|
|
96
|
+
}
|
|
97
|
+
const expected = ['args', 'controllerProcess', 'controllerProof', 'expiresAt', 'family', 'generation', 'id', 'issuedAt', 'token', 'version'];
|
|
63
98
|
if (Object.keys(request).sort().join(',') !== expected.sort().join(',')
|
|
64
99
|
|| !Array.isArray(request.args) || !request.args.every((arg) => typeof arg === 'string')) {
|
|
65
100
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'request schema or authorization is invalid');
|
|
@@ -71,15 +106,41 @@ export function validateSandboxControlRequest(value, manifest, options = {}) {
|
|
|
71
106
|
&& request.args.some((arg) => arg === '--git-worktree-root' || arg.startsWith('--git-worktree-root='))) {
|
|
72
107
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'worktree binding is reserved for the control broker');
|
|
73
108
|
}
|
|
109
|
+
if (request.controllerProcess !== null
|
|
110
|
+
|| (request.controllerProof !== null && !validControllerProof(request.controllerProof))) {
|
|
111
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller authority is invalid');
|
|
112
|
+
}
|
|
113
|
+
if (request.family !== 'task-orchestration' && request.controllerProof !== null) {
|
|
114
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'controller proof is not allowed for this family');
|
|
115
|
+
}
|
|
74
116
|
return request;
|
|
75
117
|
}
|
|
76
118
|
export function bindSandboxControlTask(request, taskId) {
|
|
77
|
-
if (request.family === 'task-create')
|
|
78
|
-
fail('SANDBOX_CONTROL_REQUEST_INVALID',
|
|
119
|
+
if (request.family === 'task-create' || request.family === 'codex-controller' || request.family === 'task-finalization') {
|
|
120
|
+
fail('SANDBOX_CONTROL_REQUEST_INVALID', `${request.family} requests do not bind a current task`);
|
|
121
|
+
}
|
|
79
122
|
if (request.args.length === 0)
|
|
80
123
|
fail('SANDBOX_CONTROL_REQUEST_INVALID', 'command arguments are required');
|
|
81
124
|
return [taskId, ...request.args.slice(1)];
|
|
82
125
|
}
|
|
126
|
+
function validControllerProcess(value) {
|
|
127
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
128
|
+
return false;
|
|
129
|
+
const processValue = value;
|
|
130
|
+
return Object.keys(processValue).sort().join(',') === 'pid,startTime'
|
|
131
|
+
&& Number.isSafeInteger(processValue.pid) && processValue.pid > 0
|
|
132
|
+
&& Number.isSafeInteger(processValue.startTime) && processValue.startTime >= 0;
|
|
133
|
+
}
|
|
134
|
+
function validControllerProof(value) {
|
|
135
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
136
|
+
return false;
|
|
137
|
+
const proof = value;
|
|
138
|
+
return Object.keys(proof).sort().join(',') === 'controllerProcess,leaseId,leaseSecret,version'
|
|
139
|
+
&& proof.version === 1
|
|
140
|
+
&& typeof proof.leaseId === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseId)
|
|
141
|
+
&& typeof proof.leaseSecret === 'string' && /^[a-f0-9]{64}$/u.test(proof.leaseSecret)
|
|
142
|
+
&& validControllerProcess(proof.controllerProcess);
|
|
143
|
+
}
|
|
83
144
|
export function controlError(error) {
|
|
84
145
|
if (error instanceof SandboxControlProtocolError) {
|
|
85
146
|
return { code: error.code, message: error.message, retryable: error.retryable };
|
|
@@ -15,9 +15,11 @@ Commands:
|
|
|
15
15
|
rebuild [--quiet] [--refresh]
|
|
16
16
|
Rebuild the sandbox image (--refresh pulls base + tools)
|
|
17
17
|
refresh Sync host Claude Code credentials to all sandbox copies
|
|
18
|
-
rm <branch> | --unbound | --purge
|
|
19
|
-
Remove one sandbox
|
|
20
|
-
|
|
18
|
+
rm <branch | TASK-id> | --unbound | --purge
|
|
19
|
+
Remove one sandbox; use a full TASK-id for task-bound
|
|
20
|
+
cleanup and a branch for branch-only cleanup. --unbound
|
|
21
|
+
removes completed task-bound and branch-only sandboxes;
|
|
22
|
+
active, blocked, and archive tasks are protected.
|
|
21
23
|
start [--recreate] <branch | TASK-id | N>
|
|
22
24
|
Verify or recover an existing sandbox container;
|
|
23
25
|
optionally replace only the container on failure
|
|
@@ -21,6 +21,11 @@ export function removeWorktreeDir(repoRoot, worktreeBase, dir, permit, { runFn =
|
|
|
21
21
|
throw new Error(`Worktree permit target mismatch: ${dir}`);
|
|
22
22
|
}
|
|
23
23
|
verifyWorktreePermit(permit);
|
|
24
|
+
if (permit.snapshot.source === 'recovered') {
|
|
25
|
+
removeManagedDir(worktreeBase, dir);
|
|
26
|
+
runSafeFn('git', ['-C', repoRoot, 'worktree', 'prune']);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
24
29
|
try {
|
|
25
30
|
runFn('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
26
31
|
}
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { isRemovedHashShortIdInput } from '../task/short-id.js';
|
|
5
5
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
6
6
|
const SHORT_ID_RE = /^\d+$/;
|
|
7
|
-
const
|
|
7
|
+
export const TASK_WORKSPACE_STATES = ['active', 'completed', 'blocked', 'archive'];
|
|
8
8
|
function resolveShortIdStrict(arg, repoRoot) {
|
|
9
9
|
const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
|
|
10
10
|
if (!fs.existsSync(scriptPath)) {
|
|
@@ -20,14 +20,28 @@ function stripQuotes(value) {
|
|
|
20
20
|
return value.replace(/^(["'])(.*)\1$/, '$2');
|
|
21
21
|
}
|
|
22
22
|
function readTaskContent(repoRoot, taskId) {
|
|
23
|
-
for (const
|
|
24
|
-
const taskPath = path.join(repoRoot, '.agents', 'workspace',
|
|
23
|
+
for (const state of TASK_WORKSPACE_STATES) {
|
|
24
|
+
const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
25
25
|
if (fs.existsSync(taskPath)) {
|
|
26
|
-
return fs.readFileSync(taskPath, 'utf8');
|
|
26
|
+
return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
throw new Error(`Task not found: ${taskId}`);
|
|
30
30
|
}
|
|
31
|
+
function taskWorkspaceCandidates(repoRoot, taskId) {
|
|
32
|
+
return TASK_WORKSPACE_STATES.flatMap((state) => {
|
|
33
|
+
const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
34
|
+
if (!fs.existsSync(taskMd))
|
|
35
|
+
return [];
|
|
36
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
37
|
+
return [{
|
|
38
|
+
taskId,
|
|
39
|
+
branch: resolveBranchFromTaskContent(content, taskId),
|
|
40
|
+
state,
|
|
41
|
+
taskMd
|
|
42
|
+
}];
|
|
43
|
+
});
|
|
44
|
+
}
|
|
31
45
|
function resolveBranchFromTaskContent(content, taskId) {
|
|
32
46
|
const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
|
|
33
47
|
if (frontmatterBranch?.[1]?.trim()) {
|
|
@@ -45,13 +59,43 @@ export function resolveTaskBranch(arg, repoRoot) {
|
|
|
45
59
|
}
|
|
46
60
|
if (SHORT_ID_RE.test(arg)) {
|
|
47
61
|
const taskId = resolveShortIdStrict(arg, repoRoot);
|
|
48
|
-
const content = readTaskContent(repoRoot, taskId);
|
|
62
|
+
const { content } = readTaskContent(repoRoot, taskId);
|
|
49
63
|
return resolveBranchFromTaskContent(content, taskId);
|
|
50
64
|
}
|
|
51
65
|
if (!TASK_ID_RE.test(arg)) {
|
|
52
66
|
return arg;
|
|
53
67
|
}
|
|
54
|
-
const content = readTaskContent(repoRoot, arg);
|
|
68
|
+
const { content } = readTaskContent(repoRoot, arg);
|
|
55
69
|
return resolveBranchFromTaskContent(content, arg);
|
|
56
70
|
}
|
|
71
|
+
export function resolveTaskWorkspace(taskId, repoRoot) {
|
|
72
|
+
if (!TASK_ID_RE.test(taskId))
|
|
73
|
+
throw new Error(`Invalid task id: ${taskId}`);
|
|
74
|
+
const matches = taskWorkspaceCandidates(repoRoot, taskId);
|
|
75
|
+
if (matches.length === 0)
|
|
76
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
77
|
+
if (matches.length !== 1) {
|
|
78
|
+
throw new Error(`SANDBOX_TASK_STATE_AMBIGUOUS: task ${taskId} exists in ${matches.map((match) => match.state).join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
return matches[0];
|
|
81
|
+
}
|
|
82
|
+
export function listTaskWorkspaces(repoRoot, state) {
|
|
83
|
+
const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
|
|
84
|
+
if (!fs.existsSync(stateRoot))
|
|
85
|
+
return [];
|
|
86
|
+
return fs.readdirSync(stateRoot, { withFileTypes: true })
|
|
87
|
+
.filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
|
|
88
|
+
.flatMap((entry) => {
|
|
89
|
+
const taskMd = path.join(stateRoot, entry.name, 'task.md');
|
|
90
|
+
if (!fs.existsSync(taskMd))
|
|
91
|
+
return [];
|
|
92
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
93
|
+
return [{
|
|
94
|
+
taskId: entry.name,
|
|
95
|
+
branch: resolveBranchFromTaskContent(content, entry.name),
|
|
96
|
+
state,
|
|
97
|
+
taskMd
|
|
98
|
+
}];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
57
101
|
//# sourceMappingURL=task-resolver.js.map
|
|
@@ -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.js';
|
|
4
|
+
import { resolveTaskWorkspace } from './task-resolver.js';
|
|
4
5
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
5
6
|
const SHORT_ID_RE = /^\d+$/;
|
|
6
7
|
function stripQuotes(value) {
|
|
@@ -78,6 +79,32 @@ export function resolveSandboxTarget(requestedRef, repoRoot) {
|
|
|
78
79
|
}
|
|
79
80
|
return { requestedRef, branch: requestedRef, workspace: { mode: 'branch-only' } };
|
|
80
81
|
}
|
|
82
|
+
export function resolveSandboxCleanupTarget(requestedRef, repoRoot, options = {}) {
|
|
83
|
+
if (isRemovedHashShortIdInput(requestedRef)) {
|
|
84
|
+
throw new Error(`Invalid task short id '${requestedRef}': task short ids must use bare digits`);
|
|
85
|
+
}
|
|
86
|
+
if (TASK_ID_RE.test(requestedRef)) {
|
|
87
|
+
const task = resolveTaskWorkspace(requestedRef, repoRoot);
|
|
88
|
+
if (!options.allowProtected && (task.state === 'blocked' || task.state === 'archive')) {
|
|
89
|
+
throw new Error(`SANDBOX_CLEANUP_STATE_UNSUPPORTED: task ${requestedRef} is ${task.state}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
requestedRef,
|
|
93
|
+
branch: task.branch,
|
|
94
|
+
workspace: { mode: 'task-bound', taskId: task.taskId },
|
|
95
|
+
taskState: task.state
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const target = resolveSandboxTarget(requestedRef, repoRoot);
|
|
99
|
+
return {
|
|
100
|
+
requestedRef,
|
|
101
|
+
branch: target.branch,
|
|
102
|
+
workspace: target.workspace.mode === 'task-bound'
|
|
103
|
+
? { mode: 'task-bound', taskId: target.workspace.taskId }
|
|
104
|
+
: { mode: 'branch-only' },
|
|
105
|
+
taskState: target.workspace.mode === 'task-bound' ? 'active' : 'branch-only'
|
|
106
|
+
};
|
|
107
|
+
}
|
|
81
108
|
export function parseSandboxWorkspaceIdentity(labels, keys) {
|
|
82
109
|
const mode = labels[keys.mode];
|
|
83
110
|
const taskId = labels[keys.taskId];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { runProbe } from './shell.js';
|
|
6
|
+
import { resolveSandboxTarget } from './workspace-identity.js';
|
|
5
7
|
function probeText(probe, cwd, args) {
|
|
6
8
|
const result = probe('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
|
7
9
|
if (result.error)
|
|
@@ -138,6 +140,197 @@ function captureOnce(worktree, probe) {
|
|
|
138
140
|
}
|
|
139
141
|
return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
|
|
140
142
|
}
|
|
143
|
+
function probeTextWithEnv(probe, repoRoot, args, env) {
|
|
144
|
+
const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
|
|
145
|
+
if (result.error)
|
|
146
|
+
throw result.error;
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
|
|
149
|
+
throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
|
|
150
|
+
}
|
|
151
|
+
return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
|
|
152
|
+
}
|
|
153
|
+
function probeMetadataCommand(probe, repoRoot, args, env) {
|
|
154
|
+
const result = probe('git', args, { cwd: repoRoot, encoding: 'utf8', env });
|
|
155
|
+
if (result.error)
|
|
156
|
+
throw result.error;
|
|
157
|
+
return result.status === 0;
|
|
158
|
+
}
|
|
159
|
+
function hasValidHeadContent(content) {
|
|
160
|
+
return /^(?:ref:\s+refs\/\S+|[0-9a-f]{40}|[0-9a-f]{64})$/i.test(content.trim());
|
|
161
|
+
}
|
|
162
|
+
function assertRecoverableMetadata(worktree, repoRoot, probe) {
|
|
163
|
+
const dotGit = path.join(worktree, '.git');
|
|
164
|
+
let dotGitStat;
|
|
165
|
+
try {
|
|
166
|
+
dotGitStat = fs.lstatSync(dotGit);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.code === 'ENOENT')
|
|
170
|
+
return;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
if (dotGitStat.isSymbolicLink() || dotGitStat.isDirectory()) {
|
|
174
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_NOT_ELIGIBLE');
|
|
175
|
+
}
|
|
176
|
+
if (!dotGitStat.isFile())
|
|
177
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
178
|
+
const content = fs.readFileSync(dotGit, 'utf8').trim();
|
|
179
|
+
const match = /^gitdir:\s*(.+)$/i.exec(content);
|
|
180
|
+
if (!match)
|
|
181
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
182
|
+
const commonDir = fs.realpathSync.native(path.resolve(repoRoot, probeText(probe, repoRoot, [
|
|
183
|
+
'rev-parse', '--git-common-dir'
|
|
184
|
+
]).trim()));
|
|
185
|
+
const adminRoot = path.join(commonDir, 'worktrees');
|
|
186
|
+
const adminPath = path.resolve(path.dirname(dotGit), match[1]);
|
|
187
|
+
const relative = path.relative(adminRoot, adminPath);
|
|
188
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || relative.includes(path.sep)) {
|
|
189
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
190
|
+
}
|
|
191
|
+
if (!fs.existsSync(adminPath))
|
|
192
|
+
return;
|
|
193
|
+
const adminStat = fs.lstatSync(adminPath);
|
|
194
|
+
if (adminStat.isSymbolicLink())
|
|
195
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
196
|
+
if (!adminStat.isDirectory())
|
|
197
|
+
return;
|
|
198
|
+
const requiredFiles = ['HEAD', 'gitdir', 'commondir', 'index'];
|
|
199
|
+
if (requiredFiles.some((name) => !fs.existsSync(path.join(adminPath, name))))
|
|
200
|
+
return;
|
|
201
|
+
for (const name of requiredFiles) {
|
|
202
|
+
const filePath = path.join(adminPath, name);
|
|
203
|
+
const stat = fs.lstatSync(filePath);
|
|
204
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
205
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
206
|
+
}
|
|
207
|
+
const adminGitdir = fs.readFileSync(path.join(adminPath, 'gitdir'), 'utf8').trim();
|
|
208
|
+
const adminCommonDir = fs.readFileSync(path.join(adminPath, 'commondir'), 'utf8').trim();
|
|
209
|
+
const adminGitdirPath = adminGitdir
|
|
210
|
+
? fs.realpathSync.native(path.resolve(adminPath, adminGitdir))
|
|
211
|
+
: '';
|
|
212
|
+
const canonicalDotGit = fs.realpathSync.native(dotGit);
|
|
213
|
+
const adminCommonDirPath = adminCommonDir
|
|
214
|
+
? fs.realpathSync.native(path.resolve(adminPath, adminCommonDir))
|
|
215
|
+
: '';
|
|
216
|
+
if (!adminGitdir || adminGitdirPath !== canonicalDotGit
|
|
217
|
+
|| !adminCommonDir || adminCommonDirPath !== commonDir) {
|
|
218
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_INVALID');
|
|
219
|
+
}
|
|
220
|
+
const headContent = fs.readFileSync(path.join(adminPath, 'HEAD'), 'utf8');
|
|
221
|
+
const indexContent = fs.readFileSync(path.join(adminPath, 'index'));
|
|
222
|
+
if (!hasValidHeadContent(headContent) || indexContent.subarray(0, 4).toString('ascii') !== 'DIRC')
|
|
223
|
+
return;
|
|
224
|
+
const validationEnv = { ...process.env };
|
|
225
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_WORK_TREE', 'GIT_PREFIX']) {
|
|
226
|
+
delete validationEnv[key];
|
|
227
|
+
}
|
|
228
|
+
validationEnv.GIT_DIR = adminPath;
|
|
229
|
+
validationEnv.GIT_WORK_TREE = path.resolve(worktree);
|
|
230
|
+
if (!probeMetadataCommand(probe, repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}'], validationEnv))
|
|
231
|
+
return;
|
|
232
|
+
if (!probeMetadataCommand(probe, repoRoot, ['ls-files', '--stage', '-z'], validationEnv))
|
|
233
|
+
return;
|
|
234
|
+
throw new Error('WORKTREE_RECOVERY_METADATA_REGISTERED');
|
|
235
|
+
}
|
|
236
|
+
function parseRecoveredStatus(raw) {
|
|
237
|
+
const records = raw.split('\0');
|
|
238
|
+
const changes = [];
|
|
239
|
+
for (let index = 0; index < records.length;) {
|
|
240
|
+
const status = records[index++] ?? '';
|
|
241
|
+
if (!status)
|
|
242
|
+
continue;
|
|
243
|
+
const code = status[0] ?? '';
|
|
244
|
+
if (code === 'R' || code === 'C') {
|
|
245
|
+
const originalPath = records[index++];
|
|
246
|
+
const nextPath = records[index++];
|
|
247
|
+
if (!originalPath || !nextPath)
|
|
248
|
+
throw new Error('Unable to parse recovered Git rename record');
|
|
249
|
+
changes.push({ indexStatus: code, worktreeStatus: ' ', path: nextPath, originalPath });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const filePath = records[index++];
|
|
253
|
+
if (!filePath)
|
|
254
|
+
throw new Error('Unable to parse recovered Git status record');
|
|
255
|
+
changes.push({
|
|
256
|
+
indexStatus: code === 'D' ? ' ' : code,
|
|
257
|
+
worktreeStatus: code === 'D' ? 'D' : ' ',
|
|
258
|
+
path: filePath
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return changes;
|
|
262
|
+
}
|
|
263
|
+
function captureRecoveredOnce(worktree, recovery, probe) {
|
|
264
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
265
|
+
const resolvedBase = path.resolve(recovery.worktreeBase);
|
|
266
|
+
const relative = path.relative(resolvedBase, resolvedWorktree);
|
|
267
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
268
|
+
throw new Error(`WORKTREE_RECOVERY_PATH_INVALID: ${resolvedWorktree}`);
|
|
269
|
+
}
|
|
270
|
+
const stat = fs.lstatSync(resolvedWorktree);
|
|
271
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
272
|
+
throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
|
|
273
|
+
const canonicalBase = fs.realpathSync.native(resolvedBase);
|
|
274
|
+
const canonicalWorktree = fs.realpathSync.native(resolvedWorktree);
|
|
275
|
+
const canonicalRelative = path.relative(canonicalBase, canonicalWorktree);
|
|
276
|
+
if (!canonicalRelative || canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
|
|
277
|
+
throw new Error('WORKTREE_RECOVERY_PATH_INVALID');
|
|
278
|
+
}
|
|
279
|
+
const resolvedWorkspace = resolveSandboxTarget(recovery.branch, recovery.repoRoot).workspace;
|
|
280
|
+
if (recovery.identitySource === 'branch-only' && resolvedWorkspace.mode !== 'branch-only') {
|
|
281
|
+
throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
|
|
282
|
+
}
|
|
283
|
+
if (recovery.identitySource === 'task-bound'
|
|
284
|
+
&& (resolvedWorkspace.mode !== 'task-bound' || resolvedWorkspace.taskId !== recovery.taskId)) {
|
|
285
|
+
throw new Error('WORKTREE_RECOVERY_IDENTITY_CHANGED');
|
|
286
|
+
}
|
|
287
|
+
assertRecoverableMetadata(resolvedWorktree, recovery.repoRoot, probe);
|
|
288
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-infra-recovered-index-'));
|
|
289
|
+
const env = { ...process.env };
|
|
290
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_PREFIX'])
|
|
291
|
+
delete env[key];
|
|
292
|
+
env.GIT_INDEX_FILE = path.join(tempDir, 'index');
|
|
293
|
+
env.GIT_WORK_TREE = resolvedWorktree;
|
|
294
|
+
try {
|
|
295
|
+
const baseline = probeTextWithEnv(probe, recovery.repoRoot, [
|
|
296
|
+
'rev-parse', '--verify', `refs/heads/${recovery.branch}^{commit}`
|
|
297
|
+
], env).trim();
|
|
298
|
+
if (!baseline)
|
|
299
|
+
throw new Error(`WORKTREE_RECOVERY_BRANCH_INVALID: ${recovery.branch}`);
|
|
300
|
+
probeTextWithEnv(probe, recovery.repoRoot, ['read-tree', baseline], env);
|
|
301
|
+
probeTextWithEnv(probe, recovery.repoRoot, ['add', '-A', '--', '.'], env);
|
|
302
|
+
const status = probeTextWithEnv(probe, recovery.repoRoot, [
|
|
303
|
+
'diff', '--cached', '--name-status', '--find-renames', '-z', '--', '.'
|
|
304
|
+
], env);
|
|
305
|
+
const changes = parseRecoveredStatus(status);
|
|
306
|
+
const hash = createHash('sha256');
|
|
307
|
+
updatePart(hash, 'source', 'recovered');
|
|
308
|
+
updatePart(hash, 'worktree', resolvedWorktree);
|
|
309
|
+
updatePart(hash, 'repo-root', path.resolve(recovery.repoRoot));
|
|
310
|
+
updatePart(hash, 'branch', recovery.branch);
|
|
311
|
+
updatePart(hash, 'baseline', baseline);
|
|
312
|
+
updatePart(hash, 'identity-source', recovery.identitySource);
|
|
313
|
+
updatePart(hash, 'task-id', recovery.taskId ?? '');
|
|
314
|
+
updatePart(hash, 'status', status);
|
|
315
|
+
for (const change of changes) {
|
|
316
|
+
hashPath(hash, resolvedWorktree, change.path, probe);
|
|
317
|
+
if (change.originalPath)
|
|
318
|
+
hashPath(hash, resolvedWorktree, change.originalPath, probe);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
worktree: resolvedWorktree,
|
|
322
|
+
branch: recovery.branch,
|
|
323
|
+
head: baseline,
|
|
324
|
+
changes,
|
|
325
|
+
identity: hash.digest('hex'),
|
|
326
|
+
source: 'recovered',
|
|
327
|
+
recovery: { ...recovery }
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
141
334
|
function inspectWorktree(worktree, { probe = runProbe } = {}) {
|
|
142
335
|
const resolvedWorktree = path.resolve(worktree);
|
|
143
336
|
try {
|
|
@@ -156,6 +349,24 @@ function inspectWorktree(worktree, { probe = runProbe } = {}) {
|
|
|
156
349
|
};
|
|
157
350
|
}
|
|
158
351
|
}
|
|
352
|
+
function inspectRecoveredWorktree(worktree, recovery, { probe = runProbe } = {}) {
|
|
353
|
+
const resolvedWorktree = path.resolve(worktree);
|
|
354
|
+
try {
|
|
355
|
+
const first = captureRecoveredOnce(resolvedWorktree, recovery, probe);
|
|
356
|
+
const second = captureRecoveredOnce(resolvedWorktree, recovery, probe);
|
|
357
|
+
if (first.identity !== second.identity) {
|
|
358
|
+
return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while recovering worktree' };
|
|
359
|
+
}
|
|
360
|
+
return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
return {
|
|
364
|
+
status: 'failed',
|
|
365
|
+
worktree: resolvedWorktree,
|
|
366
|
+
message: error instanceof Error ? error.message : String(error)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
159
370
|
function inspectWorktrees(worktrees) {
|
|
160
371
|
return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
|
|
161
372
|
.map((worktree) => inspectWorktree(worktree));
|
|
@@ -171,7 +382,9 @@ function createDiscardPermit(snapshot) {
|
|
|
171
382
|
return { mode: 'discard', snapshot };
|
|
172
383
|
}
|
|
173
384
|
function verifyWorktreePermit(permit, options = {}) {
|
|
174
|
-
const current =
|
|
385
|
+
const current = permit.snapshot.source === 'recovered' && permit.snapshot.recovery
|
|
386
|
+
? inspectRecoveredWorktree(permit.snapshot.worktree, permit.snapshot.recovery, options)
|
|
387
|
+
: inspectWorktree(permit.snapshot.worktree, options);
|
|
175
388
|
if (current.status === 'failed')
|
|
176
389
|
throw new Error(`Unable to verify worktree permit: ${current.message}`);
|
|
177
390
|
if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
|
|
@@ -192,5 +405,5 @@ function formatWorktreeSnapshot(snapshot) {
|
|
|
192
405
|
`Snapshot identity: ${snapshot.identity}`
|
|
193
406
|
].join('\n');
|
|
194
407
|
}
|
|
195
|
-
export { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectWorktree, inspectWorktrees, verifyWorktreePermit };
|
|
408
|
+
export { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectRecoveredWorktree, inspectWorktree, inspectWorktrees, verifyWorktreePermit };
|
|
196
409
|
//# sourceMappingURL=worktree-safety.js.map
|