@fitlab-ai/agent-infra 0.9.5 → 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.
Files changed (106) hide show
  1. package/dist/bin/internal-cli.js +3 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +126 -16
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +48 -4
  5. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  6. package/dist/lib/git/workflow.js +0 -1
  7. package/dist/lib/internal/codex-lifecycle.js +100 -2
  8. package/dist/lib/internal/release-workflow.js +108 -11
  9. package/dist/lib/internal/sandbox-control.js +43 -6
  10. package/dist/lib/internal/task-create.js +12 -4
  11. package/dist/lib/internal/task-orchestration.js +5 -4
  12. package/dist/lib/platform/issue-comments.js +2 -1
  13. package/dist/lib/release/workflow.js +29 -21
  14. package/dist/lib/sandbox/commands/create.js +15 -16
  15. package/dist/lib/sandbox/commands/prune.js +40 -16
  16. package/dist/lib/sandbox/commands/rm.js +318 -89
  17. package/dist/lib/sandbox/control/client.js +138 -22
  18. package/dist/lib/sandbox/control/executor.js +136 -0
  19. package/dist/lib/sandbox/control/lifecycle.js +348 -0
  20. package/dist/lib/sandbox/control/protocol.js +62 -26
  21. package/dist/lib/sandbox/control/server.js +222 -102
  22. package/dist/lib/sandbox/control/state.js +181 -0
  23. package/dist/lib/sandbox/index.js +2 -2
  24. package/dist/lib/sandbox/managed-fs.js +14 -2
  25. package/dist/lib/sandbox/mounts.js +35 -24
  26. package/dist/lib/sandbox/recovery.js +262 -45
  27. package/dist/lib/sandbox/workspace-view.js +73 -10
  28. package/dist/lib/sandbox/worktree-safety.js +196 -0
  29. package/dist/lib/server/process-control.js +6 -0
  30. package/dist/lib/task/artifact-lifecycle.js +5 -4
  31. package/dist/lib/task/codex-orchestration.js +161 -0
  32. package/dist/lib/task/commands/status.js +3 -0
  33. package/dist/lib/task/commands/validate.js +289 -0
  34. package/dist/lib/task/commit-finalization.js +36 -9
  35. package/dist/lib/task/delegation-receipts.js +25 -0
  36. package/dist/lib/task/events.js +10 -4
  37. package/dist/lib/task/finalization-artifacts.js +2 -1
  38. package/dist/lib/task/implementation-inputs.js +1 -7
  39. package/dist/lib/task/index.js +9 -0
  40. package/dist/lib/task/orchestration.js +133 -4
  41. package/dist/lib/task/verification-engine.js +38 -6
  42. package/dist/lib/task/verification.js +1 -0
  43. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +142 -15
  44. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  45. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +51 -3
  46. package/lib/agent-clients/adapters/codex.ts +1 -1
  47. package/lib/git/workflow.ts +0 -1
  48. package/lib/internal/codex-lifecycle.ts +107 -3
  49. package/lib/internal/release-workflow.ts +107 -11
  50. package/lib/internal/sandbox-control.ts +38 -6
  51. package/lib/internal/task-create.ts +16 -4
  52. package/lib/internal/task-orchestration.ts +4 -4
  53. package/lib/platform/issue-comments.ts +2 -1
  54. package/lib/release/workflow.ts +69 -20
  55. package/lib/sandbox/commands/create.ts +17 -21
  56. package/lib/sandbox/commands/prune.ts +53 -15
  57. package/lib/sandbox/commands/rm.ts +368 -103
  58. package/lib/sandbox/control/client.ts +152 -28
  59. package/lib/sandbox/control/executor.ts +163 -0
  60. package/lib/sandbox/control/lifecycle.ts +342 -0
  61. package/lib/sandbox/control/protocol.ts +96 -69
  62. package/lib/sandbox/control/server.ts +239 -110
  63. package/lib/sandbox/control/state.ts +196 -0
  64. package/lib/sandbox/index.ts +2 -2
  65. package/lib/sandbox/managed-fs.ts +17 -2
  66. package/lib/sandbox/mounts.ts +31 -13
  67. package/lib/sandbox/recovery.ts +301 -60
  68. package/lib/sandbox/workspace-view.ts +81 -11
  69. package/lib/sandbox/worktree-safety.ts +234 -0
  70. package/lib/server/process-control.ts +11 -0
  71. package/lib/task/artifact-lifecycle.ts +5 -4
  72. package/lib/task/codex-orchestration.ts +233 -0
  73. package/lib/task/commands/status.ts +2 -0
  74. package/lib/task/commands/validate.ts +282 -0
  75. package/lib/task/commit-finalization.ts +52 -9
  76. package/lib/task/delegation-receipts.ts +52 -3
  77. package/lib/task/events.ts +9 -4
  78. package/lib/task/finalization-artifacts.ts +2 -1
  79. package/lib/task/implementation-inputs.ts +1 -5
  80. package/lib/task/index.ts +8 -0
  81. package/lib/task/orchestration.ts +199 -4
  82. package/lib/task/verification-engine.ts +42 -8
  83. package/lib/task/verification.ts +2 -1
  84. package/package.json +1 -1
  85. package/templates/.agents/hooks/lifecycle-delegation.js +48 -12
  86. package/templates/.agents/rules/lifecycle-orchestration.en.md +1 -1
  87. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +1 -1
  88. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  89. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  90. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  91. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  92. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  93. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  94. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -0
  95. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -0
  96. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +44 -0
  97. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +44 -0
  98. package/templates/.agents/skills/run-task/SKILL.en.md +2 -2
  99. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +2 -2
  100. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -3
  101. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -3
  102. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  103. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  104. package/templates/.codex/hooks.json +8 -8
  105. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  106. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
@@ -9,6 +9,23 @@ export type SandboxWorkspaceView = Readonly<{
9
9
  taskMountPath: string | null;
10
10
  }>;
11
11
 
12
+ export const SANDBOX_WORKSPACE_VIEW_STATES = Object.freeze([
13
+ 'active',
14
+ 'completed',
15
+ 'blocked',
16
+ 'archive'
17
+ ] as const);
18
+
19
+ export function sandboxWorkspaceViewStatePaths(root: string): ReadonlyArray<Readonly<{
20
+ state: typeof SANDBOX_WORKSPACE_VIEW_STATES[number];
21
+ hostPath: string;
22
+ }>> {
23
+ return SANDBOX_WORKSPACE_VIEW_STATES.map((state) => ({
24
+ state,
25
+ hostPath: path.join(root, state)
26
+ }));
27
+ }
28
+
12
29
  export function sandboxWorkspaceViewPaths(params: Readonly<{
13
30
  base: string;
14
31
  project: string;
@@ -31,8 +48,10 @@ export function sandboxWorkspaceViewPaths(params: Readonly<{
31
48
 
32
49
  export type SandboxControlSetup = Readonly<{
33
50
  channelDir: string;
51
+ statusDir: string;
34
52
  manifestPath: string;
35
53
  token: string;
54
+ generation: string;
36
55
  }>;
37
56
 
38
57
  export function sandboxControlPaths(params: Readonly<{
@@ -40,13 +59,19 @@ export function sandboxControlPaths(params: Readonly<{
40
59
  project: string;
41
60
  container: string;
42
61
  identity: SandboxWorkspaceIdentity;
43
- }>): Readonly<{ root: string; channelDir: string; manifestPath: string }> {
62
+ }>): Readonly<{ root: string; channelDir: string; statusDir: string; processingDir: string; manifestPath: string }> {
44
63
  const identityKey = params.identity.mode === 'task-bound'
45
64
  ? `task-bound:${params.identity.taskId}`
46
65
  : 'branch-only';
47
66
  const digest = createHash('sha256').update(identityKey).digest('hex').slice(0, 16);
48
67
  const root = path.resolve(params.base, params.project, params.container, digest);
49
- return { root, channelDir: path.join(root, 'channel'), manifestPath: path.join(root, 'manifest.json') };
68
+ return {
69
+ root,
70
+ channelDir: path.join(root, 'channel'),
71
+ statusDir: path.join(root, 'public'),
72
+ processingDir: path.join(root, 'processing'),
73
+ manifestPath: path.join(root, 'manifest.json')
74
+ };
50
75
  }
51
76
 
52
77
  function assertSafeDirectory(directory: string, expectedBase: string): void {
@@ -59,6 +84,44 @@ function assertSafeDirectory(directory: string, expectedBase: string): void {
59
84
  }
60
85
  }
61
86
 
87
+ function assertSafeMountTargetPath(target: string, worktreeRoot: string, canonicalWorktreeRoot: string): void {
88
+ const relative = path.relative(worktreeRoot, target);
89
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
90
+ throw new Error(`Sandbox workspace mount target escapes its worktree: ${target}`);
91
+ }
92
+ let current = worktreeRoot;
93
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
94
+ current = segment ? path.join(current, segment) : current;
95
+ try {
96
+ if (fs.lstatSync(current).isSymbolicLink()) {
97
+ throw new Error(`Sandbox workspace mount target must not contain a symbolic link: ${current}`);
98
+ }
99
+ const canonicalRelative = path.relative(canonicalWorktreeRoot, fs.realpathSync.native(current));
100
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
101
+ throw new Error(`Sandbox workspace mount target escapes its real worktree: ${current}`);
102
+ }
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
105
+ }
106
+ }
107
+ }
108
+
109
+ export function prepareSandboxWorkspaceMountTargets(worktreeRoot: string): void {
110
+ const resolvedWorktreeRoot = path.resolve(worktreeRoot);
111
+ const canonicalWorktreeRoot = fs.realpathSync.native(resolvedWorktreeRoot);
112
+ const workspaceRoot = path.resolve(resolvedWorktreeRoot, '.agents', 'workspace');
113
+ const statePaths = sandboxWorkspaceViewStatePaths(workspaceRoot);
114
+ const registryTarget = path.join(workspaceRoot, 'active', '.short-ids.json');
115
+ for (const target of [workspaceRoot, ...statePaths.map(({ hostPath }) => hostPath), registryTarget]) {
116
+ assertSafeMountTargetPath(target, resolvedWorktreeRoot, canonicalWorktreeRoot);
117
+ }
118
+ fs.mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 });
119
+ for (const { hostPath } of statePaths) {
120
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
121
+ }
122
+ fs.closeSync(fs.openSync(registryTarget, 'a', 0o600));
123
+ }
124
+
62
125
  export function assertSandboxTaskSource(repoRoot: string, taskId: string): string {
63
126
  if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) throw new Error('SANDBOX_TASK_SOURCE_INVALID');
64
127
  const activeRoot = fs.realpathSync.native(path.join(repoRoot, '.agents', 'workspace', 'active'));
@@ -85,11 +148,10 @@ export function materializeSandboxWorkspaceView(params: Readonly<{
85
148
  assertSafeDirectory(projectRoot, path.resolve(params.base));
86
149
  assertSafeDirectory(root, projectRoot);
87
150
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
88
- for (const state of ['active', 'completed', 'blocked', 'archive']) {
89
- const stateDir = path.join(root, state);
90
- assertSafeDirectory(stateDir, root);
91
- fs.rmSync(stateDir, { recursive: true, force: true });
92
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
151
+ for (const { hostPath } of sandboxWorkspaceViewStatePaths(root)) {
152
+ assertSafeDirectory(hostPath, root);
153
+ fs.rmSync(hostPath, { recursive: true, force: true });
154
+ fs.mkdirSync(hostPath, { recursive: true, mode: 0o700 });
93
155
  }
94
156
 
95
157
  const active = path.join(root, 'active');
@@ -119,12 +181,16 @@ export function materializeSandboxControl(params: Readonly<{
119
181
  branch: string;
120
182
  identity: SandboxWorkspaceIdentity;
121
183
  }>): SandboxControlSetup {
122
- const { root, channelDir, manifestPath } = sandboxControlPaths(params);
184
+ const { root, channelDir, statusDir, processingDir, manifestPath } = sandboxControlPaths(params);
123
185
  assertSafeDirectory(root, path.resolve(params.base));
124
186
  const consumedDir = path.join(root, 'consumed');
125
187
  assertSafeDirectory(consumedDir, root);
126
188
  fs.rmSync(consumedDir, { recursive: true, force: true });
127
189
  fs.mkdirSync(consumedDir, { recursive: true, mode: 0o700 });
190
+ for (const directory of [statusDir, processingDir]) {
191
+ assertSafeDirectory(directory, root);
192
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
193
+ }
128
194
  for (const queue of ['requests', 'responses']) {
129
195
  const directory = path.join(channelDir, queue);
130
196
  assertSafeDirectory(directory, root);
@@ -132,9 +198,10 @@ export function materializeSandboxControl(params: Readonly<{
132
198
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
133
199
  }
134
200
  const token = randomBytes(32).toString('hex');
201
+ const generation = randomBytes(16).toString('hex');
135
202
  const repoRoot = fs.realpathSync.native(params.repoRoot);
136
203
  const manifest: SandboxControlManifest = {
137
- version: 2,
204
+ version: 3,
138
205
  repoRoot,
139
206
  worktreeRoot: fs.realpathSync.native(params.worktreeRoot),
140
207
  project: params.project,
@@ -143,8 +210,11 @@ export function materializeSandboxControl(params: Readonly<{
143
210
  mode: params.identity.mode,
144
211
  taskId: params.identity.mode === 'task-bound' ? params.identity.taskId : null,
145
212
  token,
146
- channelDir
213
+ generation,
214
+ channelDir,
215
+ publicStatusDir: statusDir,
216
+ processingDir
147
217
  };
148
218
  fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
149
- return { channelDir, manifestPath, token };
219
+ return { channelDir, statusDir, manifestPath, token, generation };
150
220
  }
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runProbe } from './shell.ts';
5
+
6
+ type WorktreeChange = {
7
+ indexStatus: string;
8
+ worktreeStatus: string;
9
+ path: string;
10
+ originalPath?: string;
11
+ };
12
+
13
+ type WorktreeSnapshot = {
14
+ worktree: string;
15
+ branch: string;
16
+ head: string;
17
+ changes: readonly WorktreeChange[];
18
+ identity: string;
19
+ };
20
+
21
+ type WorktreeInspection =
22
+ | { status: 'clean'; snapshot: WorktreeSnapshot }
23
+ | { status: 'dirty'; snapshot: WorktreeSnapshot }
24
+ | { status: 'failed'; worktree: string; message: string };
25
+
26
+ type WorktreeRemovalPermit = {
27
+ mode: 'clean' | 'discard';
28
+ snapshot: WorktreeSnapshot;
29
+ };
30
+
31
+ type Probe = typeof runProbe;
32
+
33
+ function probeText(probe: Probe, cwd: string, args: string[]): string {
34
+ const result = probe('git', ['-C', cwd, ...args], { encoding: 'utf8' });
35
+ if (result.error) throw result.error;
36
+ if (result.status !== 0) {
37
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : result.stderr.toString('utf8').trim();
38
+ throw new Error(stderr || `git ${args[0] ?? 'command'} failed with exit code ${result.status ?? 'unknown'}`);
39
+ }
40
+ return typeof result.stdout === 'string' ? result.stdout : result.stdout.toString('utf8');
41
+ }
42
+
43
+ function parseStatus(raw: string): WorktreeChange[] {
44
+ const records = raw.split('\0');
45
+ const changes: WorktreeChange[] = [];
46
+ for (let index = 0; index < records.length; index += 1) {
47
+ const record = records[index] ?? '';
48
+ if (!record || record.startsWith('# ')) continue;
49
+ if (record.startsWith('? ')) {
50
+ changes.push({ indexStatus: '?', worktreeStatus: '?', path: record.slice(2) });
51
+ continue;
52
+ }
53
+ if (record.startsWith('1 ')) {
54
+ const match = /^1 (..) \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
55
+ if (!match) throw new Error('Unable to parse git porcelain v2 ordinary record');
56
+ changes.push({ indexStatus: match[1]![0]!, worktreeStatus: match[1]![1]!, path: match[2]! });
57
+ continue;
58
+ }
59
+ if (record.startsWith('2 ')) {
60
+ const match = /^2 (..) \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
61
+ const originalPath = records[++index];
62
+ if (!match || originalPath === undefined || originalPath === '') {
63
+ throw new Error('Unable to parse git porcelain v2 rename record');
64
+ }
65
+ changes.push({
66
+ indexStatus: match[1]![0]!,
67
+ worktreeStatus: match[1]![1]!,
68
+ path: match[2]!,
69
+ originalPath
70
+ });
71
+ continue;
72
+ }
73
+ if (record.startsWith('u ')) {
74
+ const match = /^u (..) \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.*)$/s.exec(record);
75
+ if (!match) throw new Error('Unable to parse git porcelain v2 unmerged record');
76
+ changes.push({ indexStatus: match[1]![0]!, worktreeStatus: match[1]![1]!, path: match[2]! });
77
+ continue;
78
+ }
79
+ throw new Error(`Unsupported git porcelain v2 record '${record[0] ?? ''}'`);
80
+ }
81
+ return changes;
82
+ }
83
+
84
+ function updatePart(hash: ReturnType<typeof createHash>, label: string, value: string | Buffer): void {
85
+ const data = Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8');
86
+ hash.update(`${label.length}:${label}:${data.length}:`, 'utf8');
87
+ hash.update(data);
88
+ }
89
+
90
+ function resolveSnapshotPath(worktree: string, relativePath: string): string {
91
+ const resolved = path.resolve(worktree, relativePath);
92
+ const relative = path.relative(worktree, resolved);
93
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
94
+ throw new Error(`Git reported a path outside the worktree: ${JSON.stringify(relativePath)}`);
95
+ }
96
+ return resolved;
97
+ }
98
+
99
+ function hashPath(hash: ReturnType<typeof createHash>, worktree: string, relativePath: string, probe: Probe): void {
100
+ updatePart(hash, 'path', relativePath);
101
+ const target = resolveSnapshotPath(worktree, relativePath);
102
+ let stat: fs.Stats;
103
+ try {
104
+ stat = fs.lstatSync(target);
105
+ } catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
107
+ updatePart(hash, 'kind', 'absent');
108
+ return;
109
+ }
110
+ throw error;
111
+ }
112
+
113
+ updatePart(hash, 'mode', String(stat.mode & 0o7777));
114
+ if (stat.isFile()) {
115
+ updatePart(hash, 'kind', 'file');
116
+ updatePart(hash, 'content', fs.readFileSync(target));
117
+ return;
118
+ }
119
+ if (stat.isSymbolicLink()) {
120
+ updatePart(hash, 'kind', 'symlink');
121
+ updatePart(hash, 'target', fs.readlinkSync(target));
122
+ return;
123
+ }
124
+ if (stat.isDirectory()) {
125
+ updatePart(hash, 'kind', 'git-directory');
126
+ const nestedHead = probeText(probe, target, ['rev-parse', '--verify', 'HEAD']).trim();
127
+ const nestedStatus = probeText(probe, target, [
128
+ 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all', '--ignore-submodules=none'
129
+ ]);
130
+ const nestedIndex = probeText(probe, target, ['ls-files', '--stage', '-z']);
131
+ updatePart(hash, 'nested-head', nestedHead);
132
+ updatePart(hash, 'nested-status', nestedStatus);
133
+ updatePart(hash, 'nested-index', nestedIndex);
134
+ for (const change of parseStatus(nestedStatus)) {
135
+ hashPath(hash, target, change.path, probe);
136
+ if (change.originalPath) hashPath(hash, target, change.originalPath, probe);
137
+ }
138
+ return;
139
+ }
140
+ throw new Error(`Unsupported file type in dirty snapshot: ${JSON.stringify(relativePath)}`);
141
+ }
142
+
143
+ function captureOnce(worktree: string, probe: Probe): WorktreeSnapshot {
144
+ const resolvedWorktree = path.resolve(worktree);
145
+ const branch = probeText(probe, resolvedWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
146
+ const head = probeText(probe, resolvedWorktree, ['rev-parse', '--verify', 'HEAD']).trim();
147
+ const status = probeText(probe, resolvedWorktree, [
148
+ 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all', '--ignore-submodules=none'
149
+ ]);
150
+ const index = probeText(probe, resolvedWorktree, ['ls-files', '--stage', '-z']);
151
+ if (!branch) throw new Error('Unable to determine worktree branch');
152
+ if (!head) throw new Error('Unable to determine worktree HEAD');
153
+ const changes = parseStatus(status);
154
+ const hash = createHash('sha256');
155
+ updatePart(hash, 'worktree', resolvedWorktree);
156
+ updatePart(hash, 'branch', branch);
157
+ updatePart(hash, 'head', head);
158
+ updatePart(hash, 'status', status);
159
+ updatePart(hash, 'index', index);
160
+ for (const change of changes) {
161
+ hashPath(hash, resolvedWorktree, change.path, probe);
162
+ if (change.originalPath) hashPath(hash, resolvedWorktree, change.originalPath, probe);
163
+ }
164
+ return { worktree: resolvedWorktree, branch, head, changes, identity: hash.digest('hex') };
165
+ }
166
+
167
+ function inspectWorktree(worktree: string, { probe = runProbe }: { probe?: Probe } = {}): WorktreeInspection {
168
+ const resolvedWorktree = path.resolve(worktree);
169
+ try {
170
+ const first = captureOnce(resolvedWorktree, probe);
171
+ const second = captureOnce(resolvedWorktree, probe);
172
+ if (first.identity !== second.identity) {
173
+ return { status: 'failed', worktree: resolvedWorktree, message: 'Snapshot changed while inspecting worktree' };
174
+ }
175
+ return { status: second.changes.length === 0 ? 'clean' : 'dirty', snapshot: second };
176
+ } catch (error) {
177
+ return {
178
+ status: 'failed',
179
+ worktree: resolvedWorktree,
180
+ message: error instanceof Error ? error.message : String(error)
181
+ };
182
+ }
183
+ }
184
+
185
+ function inspectWorktrees(worktrees: readonly string[]): WorktreeInspection[] {
186
+ return [...new Set(worktrees.map((worktree) => path.resolve(worktree)))].sort()
187
+ .map((worktree) => inspectWorktree(worktree));
188
+ }
189
+
190
+ function createCleanPermit(snapshot: WorktreeSnapshot): WorktreeRemovalPermit {
191
+ if (snapshot.changes.length > 0) throw new Error('Cannot create a clean permit for a dirty worktree');
192
+ return { mode: 'clean', snapshot };
193
+ }
194
+
195
+ function createDiscardPermit(snapshot: WorktreeSnapshot): WorktreeRemovalPermit {
196
+ if (snapshot.changes.length === 0) throw new Error('Cannot create a discard permit for a clean worktree');
197
+ return { mode: 'discard', snapshot };
198
+ }
199
+
200
+ function verifyWorktreePermit(
201
+ permit: WorktreeRemovalPermit,
202
+ options: { probe?: Probe } = {}
203
+ ): WorktreeSnapshot {
204
+ const current = inspectWorktree(permit.snapshot.worktree, options);
205
+ if (current.status === 'failed') throw new Error(`Unable to verify worktree permit: ${current.message}`);
206
+ if (current.snapshot.identity !== permit.snapshot.identity || (permit.mode === 'clean' && current.status !== 'clean')) {
207
+ throw new Error(`Worktree changed after authorization: ${permit.snapshot.worktree}`);
208
+ }
209
+ return current.snapshot;
210
+ }
211
+
212
+ function formatWorktreeSnapshot(snapshot: WorktreeSnapshot): string {
213
+ const lines = snapshot.changes.map((change) => {
214
+ const rename = change.originalPath ? ` <- ${JSON.stringify(change.originalPath)}` : '';
215
+ return ` ${change.indexStatus}${change.worktreeStatus} ${JSON.stringify(change.path)}${rename}`;
216
+ });
217
+ return [
218
+ `Worktree: ${JSON.stringify(snapshot.worktree)}`,
219
+ `Branch: ${snapshot.branch}`,
220
+ `HEAD: ${snapshot.head}`,
221
+ ...lines,
222
+ `Snapshot identity: ${snapshot.identity}`
223
+ ].join('\n');
224
+ }
225
+
226
+ export {
227
+ createCleanPermit,
228
+ createDiscardPermit,
229
+ formatWorktreeSnapshot,
230
+ inspectWorktree,
231
+ inspectWorktrees,
232
+ verifyWorktreePermit
233
+ };
234
+ export type { WorktreeChange, WorktreeInspection, WorktreeRemovalPermit, WorktreeSnapshot };
@@ -24,6 +24,17 @@ export type StopCommand =
24
24
  | { kind: 'signal'; signal: NodeJS.Signals }
25
25
  | { kind: 'exec'; command: string; args: string[] };
26
26
 
27
+ export type ProcessTreeStopCommand =
28
+ | { kind: 'group-signal'; pid: number; signal: NodeJS.Signals }
29
+ | { kind: 'exec'; command: string; args: string[] };
30
+
31
+ export function buildProcessTreeStopCommand(pid: number, platform: NodeJS.Platform): ProcessTreeStopCommand {
32
+ if (platform === 'win32') {
33
+ return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
34
+ }
35
+ return { kind: 'group-signal', pid: -pid, signal: 'SIGTERM' };
36
+ }
37
+
27
38
  export function buildStopCommand(pid: number, platform: NodeJS.Platform): StopCommand {
28
39
  if (platform === 'win32') {
29
40
  return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
@@ -16,6 +16,7 @@ const artifactFamilyCatalog = [
16
16
  { family: 'code', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['实现报告', 'Implementation Report'] },
17
17
  { family: 'review-code', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['代码审查', 'Code Review'] },
18
18
  { family: 'manual-validation', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['人工验证', 'Manual Validation'] },
19
+ { family: 'validation-run', sectionAliases: ['实现备注', 'Implementation Notes'], heading: '实现备注', labels: ['验证运行证据', 'Validation Run Evidence'] },
19
20
  { family: 'pr-review', sectionAliases: ['审查反馈', 'Review Feedback'], heading: '审查反馈', labels: ['PR 审查报告', 'PR Review Report'] }
20
21
  ] as const;
21
22
 
@@ -257,7 +258,8 @@ const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, { family: ArtifactFamily;
257
258
  analysis: { family: 'review-analysis', requireReference: true },
258
259
  plan: { family: 'review-plan', requireReference: true },
259
260
  'review-code': { family: 'review-plan' },
260
- 'manual-validation': { family: 'review-code' }
261
+ 'manual-validation': { family: 'review-code' },
262
+ 'validation-run': { family: 'review-code' }
261
263
  };
262
264
 
263
265
  function resolveArtifactContext(taskRef: string, family: string, options: InspectOptions = {}): ArtifactContextResult {
@@ -376,12 +378,11 @@ function resolveDecisionImplementationInput(
376
378
  if (pending.length === 0) return { input: null };
377
379
  const activity = locateActivityLog(content);
378
380
  if (!activity) return { error: 'task has no unique Activity Log section' };
379
- const reviewEntries = activity.entries.filter((entry) =>
381
+ const completed = activity.entries.some((entry) =>
380
382
  /^Review Code \(Round \d+\)$/.test(entry.step) && entry.note.includes(`→ ${review.name}`)
381
383
  );
382
- const completed = reviewEntries.at(-1);
383
384
  if (!completed) return { error: `cannot find completed Activity Log identity for ${review.name}` };
384
- try { return { input: selectPendingImplementationInput(rows, completed.time) }; }
385
+ try { return { input: selectPendingImplementationInput(rows) }; }
385
386
  catch (error) { return { error: error instanceof Error ? error.message : String(error) }; }
386
387
  }
387
388
 
@@ -0,0 +1,233 @@
1
+ import {
2
+ preflightCodexLifecycleEvidence,
3
+ resolveCodexSpawnedChild,
4
+ resolveCodexTerminal,
5
+ resolveCodexThread
6
+ } from '../agent-clients/adapters/codex-lifecycle/app-server.ts';
7
+ import { createCodexLifecycleStore } from '../agent-clients/adapters/codex-lifecycle/store.ts';
8
+ import type { AgentClientId } from '../agent-clients/types.ts';
9
+ import {
10
+ activateMatchingOrchestrationDelegation,
11
+ pauseMatchingOrchestrationDelegation,
12
+ prepareOrchestrationDelegation,
13
+ reconcileMatchingOrchestrationDelegation,
14
+ sealMatchingOrchestrationDelegationWithHostEvidence
15
+ } from './orchestration.ts';
16
+ import type { OrchestrationOptions, OrchestrationResult } from './orchestration.ts';
17
+
18
+ type LifecycleStore = ReturnType<typeof createCodexLifecycleStore>;
19
+ type CodexBridgeOptions = Readonly<{
20
+ repoRoot?: string;
21
+ store?: LifecycleStore;
22
+ preflight?: typeof preflightCodexLifecycleEvidence;
23
+ resolveThread?: typeof resolveCodexThread;
24
+ resolveTerminal?: typeof resolveCodexTerminal;
25
+ orchestrationOptions?: OrchestrationOptions;
26
+ }>;
27
+
28
+ type CodexSpawnIdentity = Readonly<{
29
+ sessionId: string;
30
+ turnId: string;
31
+ toolUseId: string;
32
+ transcriptPath: string;
33
+ nativeAgent: string;
34
+ taskName: string;
35
+ requestedModel?: string;
36
+ requestedReasoningEffort?: string;
37
+ }>;
38
+
39
+ function coreOptions(options: CodexBridgeOptions): OrchestrationOptions {
40
+ return { ...options.orchestrationOptions, repoRoot: options.repoRoot ?? options.orchestrationOptions?.repoRoot };
41
+ }
42
+
43
+ function bridgeFailure(code: string, message: string): OrchestrationResult {
44
+ return {
45
+ status: 'failed', changed: false, taskId: null, run: null, next: null,
46
+ error: { code, message }
47
+ };
48
+ }
49
+
50
+ function pauseBridge(code: string, message: string, options: CodexBridgeOptions): OrchestrationResult {
51
+ const paused = pauseMatchingOrchestrationDelegation('codex', code, message, coreOptions(options));
52
+ return paused.error?.code === 'ORCHESTRATION_DELEGATION_MISSING'
53
+ ? bridgeFailure(code, message)
54
+ : paused;
55
+ }
56
+
57
+ function requiredStore(options: CodexBridgeOptions): LifecycleStore {
58
+ if (!options.store) throw new Error('Codex lifecycle store is required for orchestration bridge events');
59
+ return options.store;
60
+ }
61
+
62
+ async function prepareCodexOrchestrationDelegation(
63
+ taskRef: string,
64
+ input: Readonly<{
65
+ client: AgentClientId;
66
+ requestedModel?: string;
67
+ requestedReasoningEffort?: string;
68
+ }>,
69
+ options: CodexBridgeOptions = {}
70
+ ): Promise<OrchestrationResult> {
71
+ if (input.client !== 'codex') return prepareOrchestrationDelegation(taskRef, input, coreOptions(options));
72
+ try {
73
+ await (options.preflight ?? preflightCodexLifecycleEvidence)(options.repoRoot ?? process.cwd());
74
+ } catch (error) {
75
+ return bridgeFailure(
76
+ 'ORCHESTRATION_CLIENT_PREFLIGHT_FAILED',
77
+ error instanceof Error ? error.message : String(error)
78
+ );
79
+ }
80
+ return prepareOrchestrationDelegation(taskRef, input, coreOptions(options));
81
+ }
82
+
83
+ async function activateCodexOrchestrationDelegation(
84
+ childThreadId: string,
85
+ options: CodexBridgeOptions = {}
86
+ ): Promise<OrchestrationResult> {
87
+ try {
88
+ const store = requiredStore(options);
89
+ const resolved = await (options.resolveThread ?? resolveCodexThread)(childThreadId);
90
+ store.apply(resolved.resolution.thread);
91
+ for (const reroute of resolved.reroutes) store.apply(reroute);
92
+ store.apply(resolved.resolution.settings);
93
+ const record = store.read(childThreadId);
94
+ const evidence = record.state.startEvidence;
95
+ if (record.state.status !== 'start-ready' || !evidence) {
96
+ return pauseBridge('ORCHESTRATION_CODEX_START_EVIDENCE_INVALID', 'Codex lifecycle start evidence is not ready', options);
97
+ }
98
+ return activateMatchingOrchestrationDelegation('codex', {
99
+ nativeAgent: evidence.nativeAgent,
100
+ childId: evidence.childThreadId,
101
+ parentId: evidence.parentThreadId,
102
+ spawnMode: evidence.spawnMode,
103
+ actualModel: evidence.actualModel.value,
104
+ actualReasoningEffort: evidence.actualReasoningEffort.value,
105
+ ...(evidence.modelFallbackReason ? { modelFallbackReason: evidence.modelFallbackReason } : {}),
106
+ ...(evidence.reasoningEffortFallbackReason
107
+ ? { reasoningEffortFallbackReason: evidence.reasoningEffortFallbackReason }
108
+ : {}),
109
+ hostEvidence: {
110
+ kind: 'codex-lifecycle-v1',
111
+ hookDefinitionHash: evidence.hookDefinitionHash,
112
+ startRevision: record.revision
113
+ }
114
+ }, coreOptions(options));
115
+ } catch (error) {
116
+ return pauseBridge('ORCHESTRATION_CODEX_START_FAILED', error instanceof Error ? error.message : String(error), options);
117
+ }
118
+ }
119
+
120
+ async function activateCodexSpawnDelegation(
121
+ spawn: CodexSpawnIdentity,
122
+ options: CodexBridgeOptions = {}
123
+ ): Promise<OrchestrationResult> {
124
+ try {
125
+ const store = requiredStore(options);
126
+ const childThreadId = resolveCodexSpawnedChild(spawn.transcriptPath, spawn);
127
+ const resolved = await (options.resolveThread ?? resolveCodexThread)(childThreadId);
128
+ store.applyToSpawn(spawn, {
129
+ type: 'hook-child',
130
+ sessionId: spawn.sessionId,
131
+ turnId: spawn.turnId,
132
+ childThreadId,
133
+ parentThreadId: resolved.resolution.thread.parentThreadId,
134
+ nativeAgent: spawn.nativeAgent,
135
+ source: 'parent-rollout'
136
+ });
137
+ return activateCodexOrchestrationDelegation(childThreadId, {
138
+ ...options,
139
+ store,
140
+ resolveThread: async () => resolved
141
+ });
142
+ } catch (error) {
143
+ return pauseBridge('ORCHESTRATION_CODEX_START_FAILED', error instanceof Error ? error.message : String(error), options);
144
+ }
145
+ }
146
+
147
+ async function sealCodexOrchestrationDelegation(
148
+ childThreadId: string,
149
+ options: CodexBridgeOptions = {}
150
+ ): Promise<OrchestrationResult> {
151
+ try {
152
+ const store = requiredStore(options);
153
+ const existing = store.read(childThreadId);
154
+ if (!existing.consumer) {
155
+ store.apply(await (options.resolveTerminal ?? resolveCodexTerminal)(childThreadId));
156
+ }
157
+ const record = store.read(childThreadId);
158
+ const evidence = record.state.stopEvidence;
159
+ if (record.state.status !== 'stop-ready' || !evidence || !record.state.startEvidence) {
160
+ return pauseBridge('ORCHESTRATION_CODEX_STOP_EVIDENCE_INVALID', 'Codex lifecycle stop evidence is not ready', options);
161
+ }
162
+ return sealMatchingOrchestrationDelegationWithHostEvidence(
163
+ 'codex',
164
+ { nativeAgent: record.state.startEvidence.nativeAgent, childId: childThreadId },
165
+ (receipt) => {
166
+ const consumed = store.consume(childThreadId, receipt.id, receipt.hostEvidence?.hookDefinitionHash);
167
+ return {
168
+ stopRevision: consumed.revision,
169
+ consumer: consumed.consumer!,
170
+ consumedAt: consumed.consumedAt!
171
+ };
172
+ },
173
+ coreOptions(options)
174
+ );
175
+ } catch (error) {
176
+ return pauseBridge('ORCHESTRATION_CODEX_STOP_FAILED', error instanceof Error ? error.message : String(error), options);
177
+ }
178
+ }
179
+
180
+ async function sealCodexParentDelegation(
181
+ parentThreadId: string,
182
+ options: CodexBridgeOptions = {}
183
+ ): Promise<OrchestrationResult> {
184
+ try {
185
+ const store = requiredStore(options);
186
+ const candidates = store.findByParent(parentThreadId);
187
+ if (!candidates.length) return bridgeFailure('ORCHESTRATION_DELEGATION_MISSING', 'No matching Codex delegation is active');
188
+ for (const consumed of candidates.filter((record) => record.consumer)) {
189
+ const replayed = await sealCodexOrchestrationDelegation(consumed.state.startEvidence!.childThreadId, options);
190
+ if (replayed.error?.code !== 'ORCHESTRATION_DELEGATION_MISSING') return replayed;
191
+ }
192
+ const active = candidates.find((record) => !record.consumer);
193
+ if (!active) return bridgeFailure('ORCHESTRATION_DELEGATION_MISSING', 'No matching Codex delegation is active');
194
+ const start = active.state.startEvidence!;
195
+ const child = active.state.child!;
196
+ if (!active.state.terminal) {
197
+ store.apply(await (options.resolveTerminal ?? resolveCodexTerminal)(start.childThreadId));
198
+ }
199
+ if (!store.read(start.childThreadId).state.stop) {
200
+ store.apply({
201
+ type: 'hook-stop',
202
+ sessionId: parentThreadId,
203
+ turnId: child.turnId,
204
+ childThreadId: start.childThreadId,
205
+ nativeAgent: start.nativeAgent,
206
+ source: 'parent-rollout'
207
+ });
208
+ }
209
+ return sealCodexOrchestrationDelegation(start.childThreadId, options);
210
+ } catch (error) {
211
+ if (error instanceof Error && error.message === 'CODEX_TURN_NOT_TERMINAL') {
212
+ return bridgeFailure('ORCHESTRATION_DELEGATION_MISSING', 'The matching Codex child has not completed');
213
+ }
214
+ return pauseBridge('ORCHESTRATION_CODEX_STOP_FAILED', error instanceof Error ? error.message : String(error), options);
215
+ }
216
+ }
217
+
218
+ function reconcileCodexOrchestrationDelegation(
219
+ childThreadId: string,
220
+ options: CodexBridgeOptions = {}
221
+ ): OrchestrationResult {
222
+ return reconcileMatchingOrchestrationDelegation('codex', childThreadId, coreOptions(options));
223
+ }
224
+
225
+ export {
226
+ activateCodexOrchestrationDelegation,
227
+ activateCodexSpawnDelegation,
228
+ prepareCodexOrchestrationDelegation,
229
+ reconcileCodexOrchestrationDelegation,
230
+ sealCodexParentDelegation,
231
+ sealCodexOrchestrationDelegation
232
+ };
233
+ export type { CodexBridgeOptions };