@fitlab-ai/agent-infra 0.9.4 → 0.9.6
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 +13 -3
- package/dist/lib/agent-clients/adapter.js +2 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +492 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +175 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +255 -0
- package/dist/lib/agent-clients/adapters/codex.js +3 -3
- package/dist/lib/git/workflow.js +0 -1
- package/dist/lib/git/worktree-identity.js +64 -0
- package/dist/lib/internal/codex-lifecycle.js +300 -0
- package/dist/lib/internal/release-workflow.js +108 -11
- package/dist/lib/internal/sandbox-control.js +43 -6
- package/dist/lib/internal/task-create.js +56 -0
- package/dist/lib/internal/task-orchestration.js +71 -31
- package/dist/lib/platform/issue-comments.js +2 -1
- package/dist/lib/release/workflow.js +29 -21
- package/dist/lib/sandbox/commands/create.js +16 -16
- package/dist/lib/sandbox/commands/prune.js +40 -16
- package/dist/lib/sandbox/commands/rm.js +318 -89
- package/dist/lib/sandbox/control/client.js +152 -22
- package/dist/lib/sandbox/control/executor.js +136 -0
- package/dist/lib/sandbox/control/lifecycle.js +348 -0
- package/dist/lib/sandbox/control/protocol.js +73 -18
- package/dist/lib/sandbox/control/server.js +235 -80
- package/dist/lib/sandbox/control/state.js +181 -0
- package/dist/lib/sandbox/index.js +2 -2
- package/dist/lib/sandbox/managed-fs.js +14 -2
- package/dist/lib/sandbox/mounts.js +35 -24
- package/dist/lib/sandbox/recovery.js +262 -45
- package/dist/lib/sandbox/workspace-view.js +80 -11
- package/dist/lib/sandbox/worktree-safety.js +196 -0
- package/dist/lib/server/process-control.js +6 -0
- package/dist/lib/task/activity-log.js +32 -5
- package/dist/lib/task/artifact-lifecycle.js +5 -4
- package/dist/lib/task/codex-orchestration.js +161 -0
- package/dist/lib/task/commands/status.js +3 -0
- package/dist/lib/task/commands/validate.js +289 -0
- package/dist/lib/task/commit-finalization.js +50 -12
- package/dist/lib/task/create-service.js +137 -0
- package/dist/lib/task/create.js +333 -0
- package/dist/lib/task/delegation-receipts.js +25 -0
- package/dist/lib/task/events.js +10 -4
- package/dist/lib/task/finalization-artifacts.js +2 -1
- package/dist/lib/task/implementation-inputs.js +1 -7
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/orchestration.js +283 -18
- package/dist/lib/task/review-fingerprint.js +4 -0
- package/dist/lib/task/verification-engine.js +38 -6
- package/dist/lib/task/verification.js +1 -0
- package/dist/lib/task/workspace-snapshot.js +78 -3
- package/lib/agent-clients/adapter.ts +4 -4
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +569 -0
- package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +323 -0
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +298 -0
- package/lib/agent-clients/adapters/codex.ts +3 -3
- package/lib/git/workflow.ts +0 -1
- package/lib/git/worktree-identity.ts +86 -0
- package/lib/internal/codex-lifecycle.ts +308 -0
- package/lib/internal/release-workflow.ts +107 -11
- package/lib/internal/sandbox-control.ts +38 -6
- package/lib/internal/task-create.ts +61 -0
- package/lib/internal/task-orchestration.ts +57 -31
- package/lib/platform/issue-comments.ts +2 -1
- package/lib/release/workflow.ts +69 -20
- package/lib/sandbox/commands/create.ts +18 -21
- package/lib/sandbox/commands/prune.ts +53 -15
- package/lib/sandbox/commands/rm.ts +368 -103
- package/lib/sandbox/control/client.ts +173 -25
- package/lib/sandbox/control/executor.ts +163 -0
- package/lib/sandbox/control/lifecycle.ts +342 -0
- package/lib/sandbox/control/protocol.ts +110 -48
- package/lib/sandbox/control/server.ts +252 -85
- package/lib/sandbox/control/state.ts +196 -0
- package/lib/sandbox/index.ts +2 -2
- package/lib/sandbox/managed-fs.ts +17 -2
- package/lib/sandbox/mounts.ts +31 -13
- package/lib/sandbox/recovery.ts +301 -60
- package/lib/sandbox/workspace-view.ts +89 -12
- package/lib/sandbox/worktree-safety.ts +234 -0
- package/lib/server/process-control.ts +11 -0
- package/lib/task/activity-log.ts +46 -7
- package/lib/task/artifact-lifecycle.ts +5 -4
- package/lib/task/codex-orchestration.ts +233 -0
- package/lib/task/commands/status.ts +2 -0
- package/lib/task/commands/validate.ts +282 -0
- package/lib/task/commit-finalization.ts +75 -12
- package/lib/task/create-service.ts +171 -0
- package/lib/task/create.ts +372 -0
- package/lib/task/delegation-receipts.ts +52 -3
- package/lib/task/events.ts +9 -4
- package/lib/task/finalization-artifacts.ts +2 -1
- package/lib/task/implementation-inputs.ts +1 -5
- package/lib/task/index.ts +8 -0
- package/lib/task/orchestration.ts +381 -22
- package/lib/task/review-fingerprint.ts +9 -0
- package/lib/task/verification-engine.ts +42 -8
- package/lib/task/verification.ts +2 -1
- package/lib/task/workspace-snapshot.ts +98 -4
- package/package.json +7 -7
- package/templates/.agents/hooks/lifecycle-delegation.js +88 -8
- 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/no-mid-flow-questions.en.md +1 -0
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
- package/templates/.agents/rules/task-management.en.md +1 -0
- package/templates/.agents/rules/task-management.zh-CN.md +1 -0
- package/templates/.agents/skills/commit/SKILL.en.md +3 -9
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -10
- package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +4 -3
- package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +5 -4
- package/templates/.agents/skills/create-task/SKILL.en.md +26 -43
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +26 -43
- package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
- package/templates/.claude/commands/run-manual-validation.en.md +9 -0
- package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
- package/templates/.codex/hooks.json +50 -0
- package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
package/lib/sandbox/mounts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
worktreeDirCandidates
|
|
7
7
|
} from './constants.ts';
|
|
8
8
|
import type { SandboxConfig } from './config.ts';
|
|
9
|
+
import { sandboxWorkspaceViewStatePaths } from './workspace-view.ts';
|
|
9
10
|
|
|
10
11
|
export type SandboxBindMountDeclaration = {
|
|
11
12
|
hostPaths: string[];
|
|
@@ -24,10 +25,25 @@ export function sandboxCoreBindMounts(
|
|
|
24
25
|
shellConfigHostDir?: string;
|
|
25
26
|
workspaceViewRoot: string;
|
|
26
27
|
controlDir: string;
|
|
28
|
+
controlStatusDir: string;
|
|
27
29
|
taskSource?: string;
|
|
28
30
|
taskId?: string;
|
|
29
31
|
}
|
|
30
32
|
): SandboxBindMountDeclaration[] {
|
|
33
|
+
const taskBound = Boolean(overrides.taskSource && overrides.taskId);
|
|
34
|
+
const workspaceMounts = sandboxWorkspaceViewStatePaths(overrides.workspaceViewRoot).map(
|
|
35
|
+
({ state, hostPath }) => taskBound && state === 'active'
|
|
36
|
+
? {
|
|
37
|
+
hostPaths: [path.join(hostPath, '.short-ids.json')],
|
|
38
|
+
containerPath: '/workspace/.agents/workspace/active/.short-ids.json',
|
|
39
|
+
readOnly: true
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
hostPaths: [hostPath],
|
|
43
|
+
containerPath: path.posix.join('/workspace/.agents/workspace', state),
|
|
44
|
+
readOnly: true
|
|
45
|
+
}
|
|
46
|
+
);
|
|
31
47
|
const mounts: SandboxBindMountDeclaration[] = [
|
|
32
48
|
{
|
|
33
49
|
hostPaths: overrides.worktree
|
|
@@ -36,11 +52,16 @@ export function sandboxCoreBindMounts(
|
|
|
36
52
|
containerPath: '/workspace',
|
|
37
53
|
readOnly: false
|
|
38
54
|
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
...workspaceMounts,
|
|
56
|
+
];
|
|
57
|
+
if (taskBound) {
|
|
58
|
+
mounts.push({
|
|
59
|
+
hostPaths: [overrides.taskSource!],
|
|
60
|
+
containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId!),
|
|
61
|
+
readOnly: false
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
mounts.push(
|
|
44
65
|
{
|
|
45
66
|
hostPaths: [shareCommonDir(config)],
|
|
46
67
|
containerPath: '/share/common',
|
|
@@ -58,15 +79,12 @@ export function sandboxCoreBindMounts(
|
|
|
58
79
|
containerPath: '/home/devuser/.host-shell-config',
|
|
59
80
|
readOnly: true
|
|
60
81
|
}
|
|
61
|
-
|
|
62
|
-
if (overrides.taskSource && overrides.taskId) {
|
|
63
|
-
mounts.splice(2, 0, {
|
|
64
|
-
hostPaths: [overrides.taskSource],
|
|
65
|
-
containerPath: path.posix.join('/workspace/.agents/workspace/active', overrides.taskId),
|
|
66
|
-
readOnly: false
|
|
67
|
-
});
|
|
68
|
-
}
|
|
82
|
+
);
|
|
69
83
|
mounts.push({
|
|
84
|
+
hostPaths: [overrides.controlStatusDir],
|
|
85
|
+
containerPath: '/run/agent-infra/control-status',
|
|
86
|
+
readOnly: true
|
|
87
|
+
}, {
|
|
70
88
|
hostPaths: [overrides.controlDir],
|
|
71
89
|
containerPath: '/run/agent-infra/control',
|
|
72
90
|
readOnly: false
|
package/lib/sandbox/recovery.ts
CHANGED
|
@@ -2,13 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { nodeEntryArgs } from './control/executor.ts';
|
|
5
6
|
import {
|
|
6
7
|
containerNameCandidates,
|
|
7
8
|
sandboxBranchLabel,
|
|
8
9
|
sandboxLabel,
|
|
9
10
|
sandboxRuntimeCapabilityLabel,
|
|
10
11
|
sandboxTaskIdLabel,
|
|
11
|
-
sandboxWorkspaceModeLabel
|
|
12
|
+
sandboxWorkspaceModeLabel,
|
|
13
|
+
worktreeDirCandidates
|
|
12
14
|
} from './constants.ts';
|
|
13
15
|
import { isAgentClientId } from '../agent-clients/types.ts';
|
|
14
16
|
import {
|
|
@@ -29,8 +31,10 @@ import {
|
|
|
29
31
|
import {
|
|
30
32
|
parseSandboxWorkspaceIdentity,
|
|
31
33
|
sameSandboxWorkspaceIdentity,
|
|
34
|
+
type SandboxContainerWorkspaceIdentity,
|
|
32
35
|
type SandboxWorkspaceIdentity
|
|
33
36
|
} from './workspace-identity.ts';
|
|
37
|
+
import { inspectWorktree, type WorktreeSnapshot } from './worktree-safety.ts';
|
|
34
38
|
import { runEngine, runOkEngine, runVerboseEngine } from './shell.ts';
|
|
35
39
|
import {
|
|
36
40
|
declaredTmpfsSeedEntries,
|
|
@@ -45,10 +49,24 @@ import {
|
|
|
45
49
|
startSandboxContainer,
|
|
46
50
|
type SandboxRow
|
|
47
51
|
} from './commands/list-running.ts';
|
|
48
|
-
import { processIdentityMatches } from '../server/process-state.ts';
|
|
52
|
+
import { getProcessStartTime, processIdentityMatches, removePidFileIfMatches } from '../server/process-state.ts';
|
|
53
|
+
import {
|
|
54
|
+
acquireSandboxControlBrokerStartup,
|
|
55
|
+
isSandboxControlRootQuiescing
|
|
56
|
+
} from './control/lifecycle.ts';
|
|
57
|
+
import {
|
|
58
|
+
appendSandboxControlAudit,
|
|
59
|
+
readSandboxControlStatus
|
|
60
|
+
} from './control/state.ts';
|
|
61
|
+
import {
|
|
62
|
+
SANDBOX_CONTROL_FUTURE_SKEW_MS,
|
|
63
|
+
SANDBOX_CONTROL_STATUS_STALE_MS,
|
|
64
|
+
type SandboxControlManifest
|
|
65
|
+
} from './control/protocol.ts';
|
|
49
66
|
|
|
50
67
|
export type SandboxRecoveryFinding = {
|
|
51
68
|
repairKind: 'permissions' | 'missing-seed' | 'builtin-link' | 'hard-failure';
|
|
69
|
+
code?: string;
|
|
52
70
|
message: string;
|
|
53
71
|
path?: string;
|
|
54
72
|
seed?: TmpfsSeedEntry;
|
|
@@ -66,6 +84,11 @@ type SandboxAgentClientCheckSnapshot = {
|
|
|
66
84
|
|
|
67
85
|
export type SandboxRecoverySnapshot = {
|
|
68
86
|
identityOk: boolean;
|
|
87
|
+
containerIdValid: boolean;
|
|
88
|
+
expectedBranch: string;
|
|
89
|
+
actualBranch: string | null;
|
|
90
|
+
expectedWorkspace: SandboxWorkspaceIdentity;
|
|
91
|
+
actualWorkspace: SandboxContainerWorkspaceIdentity;
|
|
69
92
|
runtimeCapabilityOk?: boolean;
|
|
70
93
|
unexpectedCapabilityMounts?: string[];
|
|
71
94
|
mounts: Array<{
|
|
@@ -118,6 +141,7 @@ type RecoveryCommandDeps = {
|
|
|
118
141
|
runVerbose?: typeof runVerboseEngine;
|
|
119
142
|
start?: typeof startSandboxContainer;
|
|
120
143
|
fetchRows?: typeof fetchSandboxRows;
|
|
144
|
+
ensureControlBroker?: () => void | Promise<void>;
|
|
121
145
|
};
|
|
122
146
|
|
|
123
147
|
type EnsureSandboxReadyParams = {
|
|
@@ -139,11 +163,153 @@ type ExpectedMount = {
|
|
|
139
163
|
expectedRW: boolean;
|
|
140
164
|
};
|
|
141
165
|
|
|
142
|
-
function
|
|
166
|
+
async function waitForSandboxControlBrokerStatus(params: {
|
|
167
|
+
root: string;
|
|
168
|
+
statusDir: string;
|
|
169
|
+
generation: string;
|
|
170
|
+
pid: number;
|
|
171
|
+
startTime?: string;
|
|
172
|
+
}, timeoutMs = 5_000): Promise<void> {
|
|
173
|
+
const deadline = Date.now() + timeoutMs;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
if (isSandboxControlRootQuiescing(params.root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
|
|
176
|
+
try {
|
|
177
|
+
const status = readSandboxControlStatus(params.statusDir);
|
|
178
|
+
if (status.generation === params.generation && status.broker.pid === params.pid
|
|
179
|
+
&& (!params.startTime || status.broker.startTime === params.startTime)
|
|
180
|
+
&& Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
|
|
181
|
+
&& status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
|
|
182
|
+
} catch {
|
|
183
|
+
// The broker may still be publishing its initial atomic status.
|
|
184
|
+
}
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
186
|
+
}
|
|
187
|
+
throw new Error('SANDBOX_CONTROL_BROKER_START_TIMEOUT');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function startSandboxControlBroker(repoRoot: string, manifestPath: string): Promise<void> {
|
|
191
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
192
|
+
const extension = path.extname(fileURLToPath(import.meta.url));
|
|
193
|
+
const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
|
|
194
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as SandboxControlManifest;
|
|
195
|
+
const root = path.dirname(manifestPath);
|
|
196
|
+
if (isSandboxControlRootQuiescing(root)) throw new Error('SANDBOX_CONTROL_QUIESCING');
|
|
197
|
+
const brokerPath = path.join(root, 'broker.json');
|
|
198
|
+
let brokerSnapshot: string | null = null;
|
|
199
|
+
let replacedBrokerRecord = false;
|
|
200
|
+
try {
|
|
201
|
+
brokerSnapshot = fs.readFileSync(brokerPath, 'utf8');
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
204
|
+
}
|
|
205
|
+
if (brokerSnapshot !== null) {
|
|
206
|
+
type ExistingBrokerRecord = {
|
|
207
|
+
version?: unknown;
|
|
208
|
+
pid?: unknown;
|
|
209
|
+
startTime?: unknown;
|
|
210
|
+
token?: unknown;
|
|
211
|
+
generation?: unknown;
|
|
212
|
+
};
|
|
213
|
+
let broker: ExistingBrokerRecord | null = null;
|
|
214
|
+
try {
|
|
215
|
+
broker = JSON.parse(brokerSnapshot) as ExistingBrokerRecord;
|
|
216
|
+
} catch {
|
|
217
|
+
// Malformed owner records are stale and replaced below.
|
|
218
|
+
}
|
|
219
|
+
const owner = broker !== null && typeof broker.pid === 'number' && typeof broker.startTime === 'string'
|
|
220
|
+
? { version: 1 as const, pid: broker.pid, startTime: broker.startTime }
|
|
221
|
+
: null;
|
|
222
|
+
const live = owner !== null && processIdentityMatches(owner);
|
|
223
|
+
if (
|
|
224
|
+
live
|
|
225
|
+
&& owner !== null
|
|
226
|
+
&& broker !== null
|
|
227
|
+
&& broker.version === 2
|
|
228
|
+
&& broker.token === manifest.token
|
|
229
|
+
&& broker.generation === manifest.generation
|
|
230
|
+
) {
|
|
231
|
+
await waitForSandboxControlBrokerStatus({
|
|
232
|
+
root,
|
|
233
|
+
statusDir: manifest.publicStatusDir,
|
|
234
|
+
generation: manifest.generation,
|
|
235
|
+
pid: owner.pid,
|
|
236
|
+
startTime: owner.startTime
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (live && owner) {
|
|
241
|
+
const deadline = Date.now() + 5_000;
|
|
242
|
+
while (Date.now() < deadline) {
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
244
|
+
try {
|
|
245
|
+
if (fs.readFileSync(brokerPath, 'utf8') !== brokerSnapshot) {
|
|
246
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
250
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
if (!processIdentityMatches(owner)) {
|
|
255
|
+
removePidFileIfMatches(brokerPath, brokerSnapshot);
|
|
256
|
+
return startSandboxControlBroker(repoRoot, manifestPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new Error('SANDBOX_CONTROL_BROKER_OWNER_ACTIVE');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const startupStartTime = getProcessStartTime(process.pid);
|
|
263
|
+
if (!startupStartTime) throw new Error('SANDBOX_CONTROL_BROKER_IDENTITY_UNAVAILABLE');
|
|
264
|
+
const releaseStartup = await acquireSandboxControlBrokerStartup(root, {
|
|
265
|
+
pid: process.pid,
|
|
266
|
+
startTime: startupStartTime
|
|
267
|
+
});
|
|
268
|
+
let child: ReturnType<typeof spawn>;
|
|
269
|
+
try {
|
|
270
|
+
const currentBroker = fs.existsSync(brokerPath) ? fs.readFileSync(brokerPath, 'utf8') : null;
|
|
271
|
+
if (currentBroker !== brokerSnapshot) return startSandboxControlBroker(repoRoot, manifestPath);
|
|
272
|
+
if (brokerSnapshot !== null) {
|
|
273
|
+
replacedBrokerRecord = removePidFileIfMatches(brokerPath, brokerSnapshot);
|
|
274
|
+
if (!replacedBrokerRecord && fs.existsSync(brokerPath)) {
|
|
275
|
+
throw new Error('SANDBOX_CONTROL_BROKER_OWNER_TRANSITION');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (replacedBrokerRecord) {
|
|
279
|
+
appendSandboxControlAudit(manifest, 'broker-observed-crash');
|
|
280
|
+
appendSandboxControlAudit(manifest, 'broker-restart');
|
|
281
|
+
}
|
|
282
|
+
child = spawn(
|
|
283
|
+
process.execPath,
|
|
284
|
+
nodeEntryArgs(internalCli, ['sandbox-control', 'serve', '--manifest', manifestPath]),
|
|
285
|
+
{ cwd: repoRoot, detached: true, stdio: 'ignore' }
|
|
286
|
+
);
|
|
287
|
+
} finally {
|
|
288
|
+
releaseStartup();
|
|
289
|
+
}
|
|
290
|
+
let spawnError: Error | null = null;
|
|
291
|
+
child.once('error', (error) => { spawnError = error; });
|
|
292
|
+
child.unref();
|
|
293
|
+
if (!child.pid) throw new Error('SANDBOX_CONTROL_BROKER_START_FAILED');
|
|
294
|
+
try {
|
|
295
|
+
await waitForSandboxControlBrokerStatus({
|
|
296
|
+
root,
|
|
297
|
+
statusDir: manifest.publicStatusDir,
|
|
298
|
+
generation: manifest.generation,
|
|
299
|
+
pid: child.pid
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
child.kill('SIGTERM');
|
|
303
|
+
if (spawnError) throw spawnError;
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function ensureSandboxControlBroker(params: {
|
|
143
309
|
config: SandboxConfig;
|
|
144
310
|
container: string;
|
|
145
311
|
workspace: SandboxWorkspaceIdentity;
|
|
146
|
-
}): void {
|
|
312
|
+
}): Promise<void> {
|
|
147
313
|
const control = sandboxControlPaths({
|
|
148
314
|
base: params.config.controlBase ?? path.join(params.config.home, '.agent-infra', 'sandbox-control'),
|
|
149
315
|
project: params.config.project,
|
|
@@ -151,7 +317,10 @@ function ensureSandboxControlBroker(params: {
|
|
|
151
317
|
identity: params.workspace
|
|
152
318
|
});
|
|
153
319
|
if (!fs.existsSync(control.manifestPath)) return;
|
|
154
|
-
const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as
|
|
320
|
+
const manifest = JSON.parse(fs.readFileSync(control.manifestPath, 'utf8')) as SandboxControlManifest;
|
|
321
|
+
if (manifest.version !== 3) {
|
|
322
|
+
throw new Error('SANDBOX_CONTROL_MANIFEST_VERSION_INVALID: expected version 3; container-only recreation is required');
|
|
323
|
+
}
|
|
155
324
|
const brokerPath = path.join(control.root, 'broker.json');
|
|
156
325
|
try {
|
|
157
326
|
const broker = JSON.parse(fs.readFileSync(brokerPath, 'utf8')) as {
|
|
@@ -159,32 +328,38 @@ function ensureSandboxControlBroker(params: {
|
|
|
159
328
|
pid?: unknown;
|
|
160
329
|
startTime?: unknown;
|
|
161
330
|
token?: unknown;
|
|
331
|
+
generation?: unknown;
|
|
162
332
|
};
|
|
163
333
|
if (
|
|
164
|
-
broker.version ===
|
|
334
|
+
broker.version === 2
|
|
165
335
|
&& typeof broker.pid === 'number'
|
|
166
336
|
&& typeof broker.startTime === 'string'
|
|
167
337
|
&& broker.token === manifest.token
|
|
338
|
+
&& broker.generation === manifest.generation
|
|
168
339
|
&& processIdentityMatches({ version: 1, pid: broker.pid, startTime: broker.startTime })
|
|
169
|
-
)
|
|
340
|
+
) {
|
|
341
|
+
try {
|
|
342
|
+
const status = readSandboxControlStatus(control.statusDir);
|
|
343
|
+
if (status.generation === manifest.generation && status.broker.pid === broker.pid
|
|
344
|
+
&& status.broker.startTime === broker.startTime
|
|
345
|
+
&& Date.now() - status.updatedAt <= SANDBOX_CONTROL_STATUS_STALE_MS
|
|
346
|
+
&& status.updatedAt <= Date.now() + SANDBOX_CONTROL_FUTURE_SKEW_MS) return;
|
|
347
|
+
} catch {
|
|
348
|
+
// A verified live owner may still be publishing its initial status.
|
|
349
|
+
}
|
|
350
|
+
await waitForSandboxControlBrokerStatus({
|
|
351
|
+
root: control.root,
|
|
352
|
+
statusDir: control.statusDir,
|
|
353
|
+
generation: manifest.generation,
|
|
354
|
+
pid: broker.pid,
|
|
355
|
+
startTime: broker.startTime
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
170
359
|
} catch {
|
|
171
360
|
// A missing, stale, or malformed broker record is replaced below.
|
|
172
361
|
}
|
|
173
|
-
|
|
174
|
-
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
175
|
-
const extension = path.extname(fileURLToPath(import.meta.url));
|
|
176
|
-
const internalCli = path.resolve(directory, '..', '..', 'bin', `internal-cli${extension}`);
|
|
177
|
-
const child = spawn(
|
|
178
|
-
process.execPath,
|
|
179
|
-
[internalCli, 'sandbox-control', 'serve', '--manifest', control.manifestPath],
|
|
180
|
-
{ cwd: params.config.repoRoot, detached: true, stdio: 'ignore' }
|
|
181
|
-
);
|
|
182
|
-
child.once('error', () => {
|
|
183
|
-
// Readiness remains fail-closed; a later control request reports the
|
|
184
|
-
// unavailable broker without turning this detached spawn into an
|
|
185
|
-
// unhandled process error.
|
|
186
|
-
});
|
|
187
|
-
child.unref();
|
|
362
|
+
await startSandboxControlBroker(params.config.repoRoot, control.manifestPath);
|
|
188
363
|
}
|
|
189
364
|
|
|
190
365
|
function findingKey(finding: SandboxRecoveryFinding): string {
|
|
@@ -194,9 +369,22 @@ function findingKey(finding: SandboxRecoveryFinding): string {
|
|
|
194
369
|
export function classifySandboxRecovery(snapshot: SandboxRecoverySnapshot): SandboxRecoveryFinding[] {
|
|
195
370
|
const findings: SandboxRecoveryFinding[] = [];
|
|
196
371
|
if (!snapshot.identityOk) {
|
|
372
|
+
const branchOnly = snapshot.containerIdValid
|
|
373
|
+
&& snapshot.actualBranch === snapshot.expectedBranch
|
|
374
|
+
&& snapshot.expectedWorkspace.mode === 'task-bound'
|
|
375
|
+
&& snapshot.actualWorkspace.mode === 'branch-only';
|
|
376
|
+
const expectedIdentity = snapshot.expectedWorkspace.mode === 'task-bound'
|
|
377
|
+
? `task-bound:${snapshot.expectedWorkspace.taskId}`
|
|
378
|
+
: snapshot.expectedWorkspace.mode;
|
|
379
|
+
const actualIdentity = snapshot.actualWorkspace.mode === 'task-bound'
|
|
380
|
+
? `task-bound:${snapshot.actualWorkspace.taskId}`
|
|
381
|
+
: snapshot.actualWorkspace.mode;
|
|
197
382
|
findings.push({
|
|
198
383
|
repairKind: 'hard-failure',
|
|
199
|
-
|
|
384
|
+
code: branchOnly ? 'SANDBOX_CONTROL_BRANCH_ONLY' : 'SANDBOX_WORKSPACE_IDENTITY_CONFLICT',
|
|
385
|
+
message: branchOnly
|
|
386
|
+
? `Container is branch-only but the requested sandbox is ${expectedIdentity}.`
|
|
387
|
+
: `Container identity ${actualIdentity} on branch ${snapshot.actualBranch ?? 'unknown'} does not match requested ${expectedIdentity} on branch ${snapshot.expectedBranch}.`
|
|
200
388
|
});
|
|
201
389
|
}
|
|
202
390
|
if (snapshot.runtimeCapabilityOk === false) {
|
|
@@ -382,6 +570,7 @@ function expectedMounts(params: {
|
|
|
382
570
|
const core = sandboxCoreBindMounts(config, branch, {
|
|
383
571
|
workspaceViewRoot: view.root,
|
|
384
572
|
controlDir: control.channelDir,
|
|
573
|
+
controlStatusDir: control.statusDir,
|
|
385
574
|
...(params.workspace.mode === 'task-bound'
|
|
386
575
|
? {
|
|
387
576
|
taskSource: assertSandboxTaskSource(config.repoRoot, params.workspace.taskId),
|
|
@@ -603,8 +792,12 @@ export function collectSandboxRecoverySnapshot(params: {
|
|
|
603
792
|
});
|
|
604
793
|
|
|
605
794
|
return {
|
|
606
|
-
|
|
607
|
-
|
|
795
|
+
containerIdValid: typeof inspection.Id === 'string' && inspection.Id.length > 0,
|
|
796
|
+
expectedBranch: params.branch,
|
|
797
|
+
actualBranch: typeof branchLabel === 'string' ? branchLabel : null,
|
|
798
|
+
expectedWorkspace: workspace,
|
|
799
|
+
actualWorkspace: containerWorkspace,
|
|
800
|
+
identityOk: typeof inspection.Id === 'string' && inspection.Id.length > 0
|
|
608
801
|
&& branchLabel === params.branch
|
|
609
802
|
&& sameSandboxWorkspaceIdentity(containerWorkspace, workspace),
|
|
610
803
|
runtimeCapabilityOk: enforceCanonicalPlan
|
|
@@ -762,7 +955,7 @@ function repairFindings(params: {
|
|
|
762
955
|
}
|
|
763
956
|
|
|
764
957
|
function describeFindings(findings: SandboxRecoveryFinding[]): string {
|
|
765
|
-
return findings.map((finding) => finding.message).join(' ');
|
|
958
|
+
return findings.map((finding) => finding.code ? `${finding.code}: ${finding.message}` : finding.message).join(' ');
|
|
766
959
|
}
|
|
767
960
|
|
|
768
961
|
function assess(params: {
|
|
@@ -845,13 +1038,16 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
|
|
|
845
1038
|
const startFn = deps?.start ?? startSandboxContainer;
|
|
846
1039
|
const warnings: string[] = [];
|
|
847
1040
|
let failure: Error | null = null;
|
|
848
|
-
ensureSandboxControlBroker({
|
|
849
|
-
config: params.config,
|
|
850
|
-
container: params.row.name,
|
|
851
|
-
workspace: params.workspace ?? { mode: 'branch-only' }
|
|
852
|
-
});
|
|
853
|
-
|
|
854
1041
|
try {
|
|
1042
|
+
if (deps?.ensureControlBroker) {
|
|
1043
|
+
await deps.ensureControlBroker();
|
|
1044
|
+
} else {
|
|
1045
|
+
await ensureSandboxControlBroker({
|
|
1046
|
+
config: params.config,
|
|
1047
|
+
container: params.row.name,
|
|
1048
|
+
workspace: params.workspace ?? { mode: 'branch-only' }
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
855
1051
|
if (!params.row.running) {
|
|
856
1052
|
startFn(params.engine, params.row.name);
|
|
857
1053
|
const initial = assess({
|
|
@@ -975,42 +1171,87 @@ export async function ensureSandboxReady(params: EnsureSandboxReadyParams): Prom
|
|
|
975
1171
|
|
|
976
1172
|
const dataBoundary =
|
|
977
1173
|
'The existing container writable layer, ordinary /tmp data, processes, tmux sessions, and RAM state may be lost by replacement; worktree and host-managed sandbox data are preserved.';
|
|
1174
|
+
const recoveryTarget = params.workspace?.mode === 'task-bound'
|
|
1175
|
+
? params.workspace.taskId
|
|
1176
|
+
: params.branch;
|
|
978
1177
|
if (!params.allowRecreate || !params.recreate) {
|
|
979
|
-
throw new Error(
|
|
1178
|
+
throw new Error([
|
|
1179
|
+
`Sandbox recovery failed. Run 'ai sandbox start --recreate ${recoveryTarget}' on the host to authorize container-only replacement.`,
|
|
1180
|
+
dataBoundary,
|
|
1181
|
+
`Details: ${failure.message}`
|
|
1182
|
+
].join('\n'));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const existingWorktrees = worktreeDirCandidates(params.config, params.branch).filter((candidate) => fs.existsSync(candidate));
|
|
1186
|
+
if (existingWorktrees.length !== 1) {
|
|
1187
|
+
const remediation = existingWorktrees.length === 0
|
|
1188
|
+
? `Restore the worktree for '${params.branch}' before recovering the container.`
|
|
1189
|
+
: `Found ${existingWorktrees.map((candidate) => JSON.stringify(candidate)).join(', ')}; `
|
|
1190
|
+
+ 'keep the worktree in use, remove the stale directory, then retry.';
|
|
1191
|
+
throw new Error(
|
|
1192
|
+
`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected exactly one existing worktree for '${params.branch}', found ${existingWorktrees.length}. ${remediation}`
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const beforeInspection = inspectWorktree(existingWorktrees[0]!);
|
|
1196
|
+
if (beforeInspection.status === 'failed') {
|
|
1197
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: ${beforeInspection.message}`);
|
|
1198
|
+
}
|
|
1199
|
+
const before: WorktreeSnapshot = beforeInspection.snapshot;
|
|
1200
|
+
if (before.branch !== params.branch) {
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
`SANDBOX_RECOVERY_WORKTREE_SNAPSHOT_INVALID: expected branch '${params.branch}', found '${before.branch}'. `
|
|
1203
|
+
+ `Run 'git -C ${JSON.stringify(before.worktree)} checkout ${params.branch}' to restore the expected branch, then retry.`
|
|
1204
|
+
);
|
|
980
1205
|
}
|
|
981
1206
|
|
|
982
1207
|
const warning = `Sandbox recovery failed in place. Replacing only the container. ${dataBoundary}`;
|
|
983
1208
|
warnings.push(warning);
|
|
984
1209
|
(params.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)))(warning);
|
|
985
1210
|
const runVerboseFn = deps?.runVerbose ?? runVerboseEngine;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1211
|
+
let replacementResult: SandboxReadyResult | null = null;
|
|
1212
|
+
let replacementFailure: unknown = null;
|
|
1213
|
+
try {
|
|
1214
|
+
if (params.row.running) {
|
|
1215
|
+
runVerboseFn(params.engine, 'docker', ['stop', params.row.name]);
|
|
1216
|
+
}
|
|
1217
|
+
runVerboseFn(params.engine, 'docker', ['rm', params.row.name]);
|
|
1218
|
+
await params.recreate(params.branch);
|
|
991
1219
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1220
|
+
const rows = (deps?.fetchRows ?? fetchSandboxRows)(
|
|
1221
|
+
params.engine,
|
|
1222
|
+
sandboxLabel(params.config),
|
|
1223
|
+
sandboxBranchLabel(params.config)
|
|
1224
|
+
);
|
|
1225
|
+
const replacement = selectSandboxContainer(
|
|
1226
|
+
[...rows.running, ...rows.nonRunning],
|
|
1227
|
+
containerNameCandidates(params.config, params.branch)
|
|
1228
|
+
);
|
|
1229
|
+
if (!replacement?.running) {
|
|
1230
|
+
throw new Error('Replacement sandbox container was not found in a running state.');
|
|
1231
|
+
}
|
|
1232
|
+
const final = assess({
|
|
1233
|
+
config: params.config,
|
|
1234
|
+
engine: params.engine,
|
|
1235
|
+
branch: params.branch,
|
|
1236
|
+
workspace: params.workspace,
|
|
1237
|
+
container: replacement.name,
|
|
1238
|
+
deps
|
|
1239
|
+
});
|
|
1240
|
+
if (final.findings.length > 0) {
|
|
1241
|
+
throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
|
|
1242
|
+
}
|
|
1243
|
+
replacementResult = { container: replacement.name, path: 'recreated', warnings };
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
replacementFailure = error;
|
|
1003
1246
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
});
|
|
1012
|
-
if (final.findings.length > 0) {
|
|
1013
|
-
throw new Error(`Replacement sandbox readiness check failed: ${describeFindings(final.findings)}`);
|
|
1247
|
+
|
|
1248
|
+
const afterInspection = inspectWorktree(before.worktree);
|
|
1249
|
+
if (afterInspection.status === 'failed' || afterInspection.snapshot.identity !== before.identity) {
|
|
1250
|
+
const detail = afterInspection.status === 'failed'
|
|
1251
|
+
? afterInspection.message
|
|
1252
|
+
: `snapshot changed from ${before.identity} to ${afterInspection.snapshot.identity}`;
|
|
1253
|
+
throw new Error(`SANDBOX_RECOVERY_WORKTREE_CHANGED: ${detail}`, replacementFailure === null ? undefined : { cause: replacementFailure });
|
|
1014
1254
|
}
|
|
1015
|
-
|
|
1255
|
+
if (replacementFailure !== null) throw replacementFailure;
|
|
1256
|
+
return replacementResult!;
|
|
1016
1257
|
}
|