holo-codex 0.1.0
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/.agents/plugins/marketplace.json +20 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/README.zh-CN.md +215 -0
- package/SECURITY.md +39 -0
- package/assets/brand/README.md +35 -0
- package/assets/brand/holo-codex-icon.svg +28 -0
- package/assets/brand/holo-codex-lockup.svg +49 -0
- package/assets/brand/holo-codex-mark.svg +33 -0
- package/assets/brand/holo-codex-plugin-card.png +0 -0
- package/assets/brand/holo-codex-plugin-card.svg +81 -0
- package/assets/brand/holo-codex-readme-hero.png +0 -0
- package/assets/brand/holo-codex-readme-hero.svg +140 -0
- package/assets/brand/holo-codex-social-preview.png +0 -0
- package/assets/brand/holo-codex-social-preview.svg +130 -0
- package/assets/brand/holo-codex-wordmark-options.svg +52 -0
- package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
- package/docs/examples/generic-loop-repo-hygiene.md +168 -0
- package/docs/install.md +190 -0
- package/docs/local-release-readiness.md +206 -0
- package/docs/release-checklist.md +144 -0
- package/docs/self-bootstrap.md +150 -0
- package/docs/trust-and-safety.md +45 -0
- package/package.json +83 -0
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
- package/plugins/autonomous-pr-loop/.mcp.json +13 -0
- package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
- package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
- package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
- package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
- package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
- package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
- package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
- package/plugins/autonomous-pr-loop/core/command.ts +47 -0
- package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
- package/plugins/autonomous-pr-loop/core/config.ts +293 -0
- package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
- package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
- package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
- package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
- package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
- package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
- package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
- package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
- package/plugins/autonomous-pr-loop/core/git.ts +213 -0
- package/plugins/autonomous-pr-loop/core/github.ts +269 -0
- package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
- package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
- package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
- package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
- package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
- package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
- package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
- package/plugins/autonomous-pr-loop/core/index.ts +32 -0
- package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
- package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
- package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
- package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
- package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
- package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
- package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
- package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
- package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
- package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
- package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
- package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
- package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
- package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
- package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
- package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
- package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
- package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
- package/plugins/autonomous-pr-loop/core/types.ts +567 -0
- package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
- package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
- package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
- package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
- package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
- package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
- package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
- package/plugins/autonomous-pr-loop/package.json +9 -0
- package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
- package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
- package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
- package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
- package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
- package/plugins/autonomous-pr-loop/ui/index.html +26 -0
- package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
- package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
- package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
- package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
- package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
- package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
- package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
- package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
- package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
- package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
- package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
- package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
- package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
- package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { evaluatePolicy } from "./command-runner.js";
|
|
7
|
+
import { isRecord } from "./config.js";
|
|
8
|
+
import { writeArtifact } from "./artifacts.js";
|
|
9
|
+
import { AgentLoopError } from "./errors.js";
|
|
10
|
+
import { PR_LOOP_SHAPE } from "./loop-shapes.js";
|
|
11
|
+
import { resolveProfile } from "./profiles.js";
|
|
12
|
+
import { buildWorkerPrompt, workerSandbox } from "./worker-prompts.js";
|
|
13
|
+
import { resolveWorkerPolicy } from "./worker-policy.js";
|
|
14
|
+
import { createWorkerJsonlStreamIngestor } from "./worker-events.js";
|
|
15
|
+
import { captureScopeBaseline, evaluateWorkerScope } from "./scope-guard.js";
|
|
16
|
+
import type { AgentLoopState, ArtifactRecord } from "./state-types.js";
|
|
17
|
+
import type {
|
|
18
|
+
AgentLoopConfig,
|
|
19
|
+
AgentLoopRun,
|
|
20
|
+
AgentLoopStorage,
|
|
21
|
+
ScopeGuardReport,
|
|
22
|
+
WorkerCommandPlan,
|
|
23
|
+
WorkerResult,
|
|
24
|
+
WorkerRun,
|
|
25
|
+
WorkerType
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
export interface WorkerExecutionResult {
|
|
29
|
+
worker: WorkerRun;
|
|
30
|
+
result?: WorkerResult;
|
|
31
|
+
scope?: ScopeGuardReport;
|
|
32
|
+
artifacts: ArtifactRecord[];
|
|
33
|
+
commandPlan: WorkerCommandPlan;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Execute or dry-run a delegated Codex worker for one state-machine state. */
|
|
37
|
+
export async function executeWorker(input: {
|
|
38
|
+
repoRoot: string;
|
|
39
|
+
storage: AgentLoopStorage;
|
|
40
|
+
run: AgentLoopRun;
|
|
41
|
+
config: AgentLoopConfig;
|
|
42
|
+
state: AgentLoopState;
|
|
43
|
+
type: WorkerType;
|
|
44
|
+
dryRun: boolean;
|
|
45
|
+
context?: unknown;
|
|
46
|
+
signal?: AbortSignal | undefined;
|
|
47
|
+
}): Promise<WorkerExecutionResult> {
|
|
48
|
+
if (input.config.workerBackend === "codex-app-server") {
|
|
49
|
+
const probe = await probeCodexAppServer(input.repoRoot, input.config.workerTimeoutMs);
|
|
50
|
+
const probeArtifact = writeArtifact(
|
|
51
|
+
input.repoRoot,
|
|
52
|
+
input.storage,
|
|
53
|
+
input.run.id,
|
|
54
|
+
"log",
|
|
55
|
+
"codex-app-server-probe.json",
|
|
56
|
+
`${JSON.stringify(probe, null, 2)}\n`
|
|
57
|
+
);
|
|
58
|
+
const code = probe.status === "success" ? "worker_failed" : "required_tool_unavailable";
|
|
59
|
+
const message = probe.status === "success"
|
|
60
|
+
? "codex-app-server capability probe succeeded, but worker execution through app-server is not implemented in PR H2."
|
|
61
|
+
: "codex-app-server backend is unavailable.";
|
|
62
|
+
throw new AgentLoopError(code, message, {
|
|
63
|
+
details: { backend: "codex-app-server", probe, artifactId: probeArtifact.id },
|
|
64
|
+
exitCode: 2
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
clearOrRejectRunningWorker(input.storage, input.config.workerTimeoutMs);
|
|
68
|
+
const policy = resolveWorkerPolicy({
|
|
69
|
+
config: input.config,
|
|
70
|
+
state: input.state,
|
|
71
|
+
workerType: input.type
|
|
72
|
+
});
|
|
73
|
+
const worker = input.storage.createWorker({
|
|
74
|
+
runId: input.run.id,
|
|
75
|
+
type: input.type,
|
|
76
|
+
backend: input.config.workerBackend,
|
|
77
|
+
attempt: 0,
|
|
78
|
+
resumeUsed: false
|
|
79
|
+
});
|
|
80
|
+
const prompt = buildWorkerPrompt({ ...input, profile: resolveProfile(input.config, input.state), policy });
|
|
81
|
+
const promptArtifact = writeArtifact(
|
|
82
|
+
input.repoRoot,
|
|
83
|
+
input.storage,
|
|
84
|
+
input.run.id,
|
|
85
|
+
"worker-prompt",
|
|
86
|
+
`${worker.id}.md`,
|
|
87
|
+
prompt
|
|
88
|
+
);
|
|
89
|
+
const commandPlan = buildWorkerCommandPlan(input.repoRoot, input.run.id, input.config, input.type, promptArtifact.path, worker.id, policy.sandbox);
|
|
90
|
+
assertWorkerCommandAllowed(commandPlan);
|
|
91
|
+
if (input.dryRun) {
|
|
92
|
+
const planArtifact = writeArtifact(
|
|
93
|
+
input.repoRoot,
|
|
94
|
+
input.storage,
|
|
95
|
+
input.run.id,
|
|
96
|
+
"dry-run-plan",
|
|
97
|
+
`${worker.id}-worker-command.json`,
|
|
98
|
+
`${JSON.stringify(commandPlan, null, 2)}\n`
|
|
99
|
+
);
|
|
100
|
+
const updated = input.storage.updateWorker(worker.id, {
|
|
101
|
+
status: "succeeded",
|
|
102
|
+
completedAt: new Date().toISOString()
|
|
103
|
+
});
|
|
104
|
+
input.storage.appendEvent({
|
|
105
|
+
runId: input.run.id,
|
|
106
|
+
kind: "worker_dry_run",
|
|
107
|
+
message: `Prepared ${input.type} worker prompt without executing codex.`,
|
|
108
|
+
payload: { workerId: worker.id, commandPlan },
|
|
109
|
+
artifactIds: [promptArtifact.id, planArtifact.id]
|
|
110
|
+
});
|
|
111
|
+
return { worker: updated, artifacts: [promptArtifact, planArtifact], commandPlan };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return await runWithRetry({
|
|
115
|
+
...input,
|
|
116
|
+
initialWorker: worker,
|
|
117
|
+
prompt,
|
|
118
|
+
promptArtifact,
|
|
119
|
+
commandPlan
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Return the PR D worker type for a state, if the state delegates to a worker. */
|
|
124
|
+
export function workerTypeForState(state: AgentLoopState, context?: { ciFailed?: boolean }): WorkerType | undefined {
|
|
125
|
+
if (state === "FIX_REVIEW" && context?.ciFailed) {
|
|
126
|
+
return "ci-fix";
|
|
127
|
+
}
|
|
128
|
+
return PR_LOOP_SHAPE.defaultRoleForState(state);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildWorkerCommandPlan(
|
|
132
|
+
repoRoot: string,
|
|
133
|
+
runId: string,
|
|
134
|
+
config: AgentLoopConfig,
|
|
135
|
+
type: WorkerType,
|
|
136
|
+
promptPath: string,
|
|
137
|
+
workerId: string,
|
|
138
|
+
sandbox: "read-only" | "workspace-write" = workerSandbox(type),
|
|
139
|
+
resumeThreadId?: string
|
|
140
|
+
): WorkerCommandPlan {
|
|
141
|
+
const outputSchemaPath = join(pluginRoot(), "plugins", "autonomous-pr-loop", "schemas", "worker-result.schema.json");
|
|
142
|
+
const outputLastMessagePath = join(
|
|
143
|
+
repoRoot,
|
|
144
|
+
".agent-loop",
|
|
145
|
+
"artifacts",
|
|
146
|
+
runId,
|
|
147
|
+
"worker-result",
|
|
148
|
+
`${workerId}-worker-final.json`
|
|
149
|
+
);
|
|
150
|
+
mkdirSync(dirname(outputLastMessagePath), { recursive: true });
|
|
151
|
+
const args = [
|
|
152
|
+
"exec",
|
|
153
|
+
"-C",
|
|
154
|
+
repoRoot,
|
|
155
|
+
"-s",
|
|
156
|
+
sandbox,
|
|
157
|
+
"--json",
|
|
158
|
+
"--output-schema",
|
|
159
|
+
outputSchemaPath,
|
|
160
|
+
"--output-last-message",
|
|
161
|
+
outputLastMessagePath
|
|
162
|
+
];
|
|
163
|
+
if (config.workerEphemeral) {
|
|
164
|
+
args.push("--ephemeral");
|
|
165
|
+
}
|
|
166
|
+
if (resumeThreadId) {
|
|
167
|
+
args.push("resume", resumeThreadId, "Retry once. Return valid JSON matching the required schema.");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
file: "codex",
|
|
171
|
+
args,
|
|
172
|
+
cwd: repoRoot,
|
|
173
|
+
sandbox,
|
|
174
|
+
promptPath,
|
|
175
|
+
outputSchemaPath,
|
|
176
|
+
outputLastMessagePath
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pluginRoot(): string {
|
|
181
|
+
return resolve(import.meta.dirname, "../../..");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function assertWorkerCommandAllowed(plan: WorkerCommandPlan): void {
|
|
185
|
+
const policy = evaluatePolicy({ file: plan.file, args: plan.args });
|
|
186
|
+
if (!policy.allowed) {
|
|
187
|
+
throw new AgentLoopError("policy_violation", policy.reason ?? "Worker command rejected by policy.", {
|
|
188
|
+
details: { plan },
|
|
189
|
+
exitCode: 2
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function runWithRetry(input: {
|
|
195
|
+
repoRoot: string;
|
|
196
|
+
storage: AgentLoopStorage;
|
|
197
|
+
run: AgentLoopRun;
|
|
198
|
+
config: AgentLoopConfig;
|
|
199
|
+
state: AgentLoopState;
|
|
200
|
+
type: WorkerType;
|
|
201
|
+
context?: unknown;
|
|
202
|
+
signal?: AbortSignal | undefined;
|
|
203
|
+
initialWorker: WorkerRun;
|
|
204
|
+
prompt: string;
|
|
205
|
+
promptArtifact: ArtifactRecord;
|
|
206
|
+
commandPlan: WorkerCommandPlan;
|
|
207
|
+
}): Promise<WorkerExecutionResult> {
|
|
208
|
+
let worker = input.initialWorker;
|
|
209
|
+
let commandPlan = input.commandPlan;
|
|
210
|
+
let threadId: string | undefined;
|
|
211
|
+
for (let attempt = 0; attempt <= input.config.workerMaxRetries; attempt += 1) {
|
|
212
|
+
const spawnContext = createWorkerSpawnContext(commandPlan.cwd, worker.id, commandPlan.file);
|
|
213
|
+
const baseline = captureScopeBaseline(input.repoRoot);
|
|
214
|
+
const ingestor = createWorkerJsonlStreamIngestor({
|
|
215
|
+
repoRoot: input.repoRoot,
|
|
216
|
+
storage: input.storage,
|
|
217
|
+
runId: input.run.id,
|
|
218
|
+
workerId: worker.id,
|
|
219
|
+
backend: input.config.workerBackend
|
|
220
|
+
});
|
|
221
|
+
const runResult = await spawnCodexWorker(
|
|
222
|
+
commandPlan,
|
|
223
|
+
input.prompt,
|
|
224
|
+
input.config.workerTimeoutMs,
|
|
225
|
+
spawnContext,
|
|
226
|
+
(chunk) => ingestor.ingestChunk(chunk),
|
|
227
|
+
input.signal
|
|
228
|
+
);
|
|
229
|
+
const ingest = ingestor.finalize();
|
|
230
|
+
threadId = ingest.threadId ?? threadId;
|
|
231
|
+
const rawJsonlArtifactId = ingest.rawJsonlArtifactId;
|
|
232
|
+
if (runResult.timedOut) {
|
|
233
|
+
input.storage.updateWorker(worker.id, {
|
|
234
|
+
status: "timed_out",
|
|
235
|
+
...(threadId ? { threadId } : {}),
|
|
236
|
+
completedAt: new Date().toISOString(),
|
|
237
|
+
exitCode: 124,
|
|
238
|
+
rawJsonlArtifactId,
|
|
239
|
+
error: "Worker timed out."
|
|
240
|
+
});
|
|
241
|
+
throw new AgentLoopError("worker_timeout", "Codex worker timed out.", {
|
|
242
|
+
details: workerGateDetails(worker, {
|
|
243
|
+
...(threadId ? { threadId } : {}),
|
|
244
|
+
timeoutMs: input.config.workerTimeoutMs
|
|
245
|
+
}),
|
|
246
|
+
exitCode: 2
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (runResult.exitCode !== 0) {
|
|
250
|
+
input.storage.updateWorker(worker.id, {
|
|
251
|
+
status: "failed",
|
|
252
|
+
...(threadId ? { threadId } : {}),
|
|
253
|
+
completedAt: new Date().toISOString(),
|
|
254
|
+
exitCode: runResult.exitCode,
|
|
255
|
+
rawJsonlArtifactId,
|
|
256
|
+
error: runResult.stderr || `codex exited ${runResult.exitCode}`
|
|
257
|
+
});
|
|
258
|
+
if (attempt < input.config.workerMaxRetries) {
|
|
259
|
+
worker = input.storage.createWorker({
|
|
260
|
+
runId: input.run.id,
|
|
261
|
+
type: input.type,
|
|
262
|
+
backend: input.config.workerBackend,
|
|
263
|
+
attempt: attempt + 1,
|
|
264
|
+
resumeUsed: threadId !== undefined
|
|
265
|
+
});
|
|
266
|
+
commandPlan = buildWorkerCommandPlan(
|
|
267
|
+
input.repoRoot,
|
|
268
|
+
input.run.id,
|
|
269
|
+
input.config,
|
|
270
|
+
input.type,
|
|
271
|
+
input.promptArtifact.path,
|
|
272
|
+
worker.id,
|
|
273
|
+
resolveWorkerPolicy({ config: input.config, state: input.state, workerType: input.type }).sandbox,
|
|
274
|
+
threadId
|
|
275
|
+
);
|
|
276
|
+
assertWorkerCommandAllowed(commandPlan);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
throw new AgentLoopError("worker_failed", "Codex worker failed.", {
|
|
280
|
+
details: workerGateDetails(worker, {
|
|
281
|
+
...(threadId ? { threadId } : {}),
|
|
282
|
+
exitCode: runResult.exitCode,
|
|
283
|
+
error: runResult.stderr || `codex exited ${runResult.exitCode}`
|
|
284
|
+
}),
|
|
285
|
+
exitCode: 1
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const parsed = parseWorkerResult(commandPlan.outputLastMessagePath);
|
|
289
|
+
if (!parsed.ok) {
|
|
290
|
+
input.storage.updateWorker(worker.id, {
|
|
291
|
+
status: "invalid_output",
|
|
292
|
+
...(threadId ? { threadId } : {}),
|
|
293
|
+
completedAt: new Date().toISOString(),
|
|
294
|
+
exitCode: 0,
|
|
295
|
+
rawJsonlArtifactId,
|
|
296
|
+
error: parsed.error
|
|
297
|
+
});
|
|
298
|
+
if (attempt < input.config.workerMaxRetries) {
|
|
299
|
+
worker = input.storage.createWorker({
|
|
300
|
+
runId: input.run.id,
|
|
301
|
+
type: input.type,
|
|
302
|
+
backend: input.config.workerBackend,
|
|
303
|
+
attempt: attempt + 1,
|
|
304
|
+
resumeUsed: threadId !== undefined
|
|
305
|
+
});
|
|
306
|
+
commandPlan = buildWorkerCommandPlan(
|
|
307
|
+
input.repoRoot,
|
|
308
|
+
input.run.id,
|
|
309
|
+
input.config,
|
|
310
|
+
input.type,
|
|
311
|
+
input.promptArtifact.path,
|
|
312
|
+
worker.id,
|
|
313
|
+
resolveWorkerPolicy({ config: input.config, state: input.state, workerType: input.type }).sandbox,
|
|
314
|
+
threadId
|
|
315
|
+
);
|
|
316
|
+
assertWorkerCommandAllowed(commandPlan);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
throw new AgentLoopError("worker_output_invalid", "Worker output did not match schema.", {
|
|
320
|
+
details: workerGateDetails(worker, {
|
|
321
|
+
...(threadId ? { threadId } : {}),
|
|
322
|
+
error: parsed.error
|
|
323
|
+
}),
|
|
324
|
+
exitCode: 2
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const resultArtifact = persistExistingResult(
|
|
328
|
+
input.repoRoot,
|
|
329
|
+
input.storage,
|
|
330
|
+
input.run.id,
|
|
331
|
+
commandPlan.outputLastMessagePath,
|
|
332
|
+
`${worker.id}-worker-final.json`
|
|
333
|
+
);
|
|
334
|
+
if (!parsed.result.ok) {
|
|
335
|
+
input.storage.updateWorker(worker.id, {
|
|
336
|
+
status: "failed",
|
|
337
|
+
...(threadId ? { threadId } : {}),
|
|
338
|
+
completedAt: new Date().toISOString(),
|
|
339
|
+
exitCode: 0,
|
|
340
|
+
resultArtifactId: resultArtifact.id,
|
|
341
|
+
rawJsonlArtifactId,
|
|
342
|
+
error: parsed.result.error?.message ?? parsed.result.summary
|
|
343
|
+
});
|
|
344
|
+
throw new AgentLoopError("worker_failed", "Worker reported failure.", {
|
|
345
|
+
details: workerGateDetails(worker, {
|
|
346
|
+
...(threadId ? { threadId } : {}),
|
|
347
|
+
error: parsed.result.error?.message ?? parsed.result.summary,
|
|
348
|
+
result: parsed.result
|
|
349
|
+
}),
|
|
350
|
+
exitCode: 1
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const scope = evaluateWorkerScope({
|
|
354
|
+
repoRoot: input.repoRoot,
|
|
355
|
+
storage: input.storage,
|
|
356
|
+
runId: input.run.id,
|
|
357
|
+
workerId: worker.id,
|
|
358
|
+
config: input.config,
|
|
359
|
+
baseline,
|
|
360
|
+
result: parsed.result,
|
|
361
|
+
...optionalAllowedPaths(input.type, input.config, input.state),
|
|
362
|
+
...(input.config.loopShape === "generic-loop" ? { outOfScopeGate: "generic_scope_change_requested" as const } : {})
|
|
363
|
+
});
|
|
364
|
+
const updated = input.storage.updateWorker(worker.id, {
|
|
365
|
+
status: "succeeded",
|
|
366
|
+
...(threadId ? { threadId } : {}),
|
|
367
|
+
completedAt: new Date().toISOString(),
|
|
368
|
+
exitCode: 0,
|
|
369
|
+
resultArtifactId: resultArtifact.id,
|
|
370
|
+
rawJsonlArtifactId
|
|
371
|
+
});
|
|
372
|
+
input.storage.appendEvent({
|
|
373
|
+
runId: input.run.id,
|
|
374
|
+
kind: "worker_completed",
|
|
375
|
+
message: `${input.type} worker completed.`,
|
|
376
|
+
payload: { workerId: worker.id, result: parsed.result, scope },
|
|
377
|
+
artifactIds: [input.promptArtifact.id, resultArtifact.id, rawJsonlArtifactId]
|
|
378
|
+
});
|
|
379
|
+
if (scope.gate) {
|
|
380
|
+
throw new AgentLoopError(scope.gate, "Worker scope guard blocked progress.", {
|
|
381
|
+
details: scope.gate === "generic_scope_change_requested" ? genericScopeGateDetails(input.config, input.state, scope) : scope,
|
|
382
|
+
exitCode: 2
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
worker: updated,
|
|
387
|
+
result: parsed.result,
|
|
388
|
+
scope,
|
|
389
|
+
artifacts: [input.promptArtifact, resultArtifact],
|
|
390
|
+
commandPlan
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
throw new AgentLoopError("storage_error", "Worker retry loop ended unexpectedly.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function workerGateDetails(worker: WorkerRun, extra: Record<string, unknown>): Record<string, unknown> {
|
|
397
|
+
return {
|
|
398
|
+
workerId: worker.id,
|
|
399
|
+
workerType: worker.type,
|
|
400
|
+
attempt: worker.attempt,
|
|
401
|
+
...(worker.threadId === undefined ? {} : { threadId: worker.threadId }),
|
|
402
|
+
...extra
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface AppServerProbeResult {
|
|
407
|
+
success: boolean;
|
|
408
|
+
status: "success" | "command_missing" | "help_failed" | "startup_failed" | "handshake_timeout" | "protocol_mismatch";
|
|
409
|
+
helpExitCode?: number;
|
|
410
|
+
stderr?: string;
|
|
411
|
+
responsePreview?: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function probeCodexAppServer(repoRoot: string, workerTimeoutMs: number): Promise<AppServerProbeResult> {
|
|
415
|
+
const codexPath = resolveOptionalExecutable("codex", process.env.PATH ?? "");
|
|
416
|
+
if (!codexPath) {
|
|
417
|
+
return { success: false, status: "command_missing" };
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
execFileSync(codexPath, ["app-server", "--help"], {
|
|
421
|
+
cwd: repoRoot,
|
|
422
|
+
encoding: "utf8",
|
|
423
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
424
|
+
timeout: Math.min(workerTimeoutMs, 5_000)
|
|
425
|
+
});
|
|
426
|
+
} catch (error) {
|
|
427
|
+
const helpExitCode = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : undefined;
|
|
428
|
+
const result: AppServerProbeResult = {
|
|
429
|
+
success: false,
|
|
430
|
+
status: "help_failed",
|
|
431
|
+
stderr: error instanceof Error ? error.message : String(error)
|
|
432
|
+
};
|
|
433
|
+
if (typeof helpExitCode === "number" && Number.isFinite(helpExitCode)) {
|
|
434
|
+
result.helpExitCode = helpExitCode;
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
}
|
|
438
|
+
return await new Promise((resolve) => {
|
|
439
|
+
const child = spawn(codexPath, ["app-server", "--listen", "stdio://"], {
|
|
440
|
+
cwd: repoRoot,
|
|
441
|
+
env: process.env,
|
|
442
|
+
shell: false,
|
|
443
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
444
|
+
});
|
|
445
|
+
let stdout = "";
|
|
446
|
+
let stderr = "";
|
|
447
|
+
let settled = false;
|
|
448
|
+
const finish = (result: AppServerProbeResult): void => {
|
|
449
|
+
if (settled) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
settled = true;
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
child.kill("SIGTERM");
|
|
455
|
+
resolve(result);
|
|
456
|
+
};
|
|
457
|
+
const timer = setTimeout(() => {
|
|
458
|
+
finish({ success: false, status: "handshake_timeout", responsePreview: stdout.slice(0, 500), stderr: stderr.slice(0, 500) });
|
|
459
|
+
}, 3_000);
|
|
460
|
+
child.stdout.setEncoding("utf8");
|
|
461
|
+
child.stderr.setEncoding("utf8");
|
|
462
|
+
child.stdout.on("data", (chunk: string) => {
|
|
463
|
+
stdout += chunk;
|
|
464
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
465
|
+
for (const line of lines) {
|
|
466
|
+
try {
|
|
467
|
+
const parsed = JSON.parse(line) as { id?: unknown; result?: unknown; error?: unknown };
|
|
468
|
+
if (parsed.id === 1 && parsed.result !== undefined) {
|
|
469
|
+
finish({ success: true, status: "success", responsePreview: line.slice(0, 500) });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (parsed.id === 1 && parsed.error !== undefined) {
|
|
473
|
+
finish({ success: false, status: "protocol_mismatch", responsePreview: line.slice(0, 500) });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
// Keep waiting until a complete JSON-RPC line or timeout.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
child.stderr.on("data", (chunk: string) => {
|
|
482
|
+
stderr += chunk;
|
|
483
|
+
});
|
|
484
|
+
child.on("error", (error) => {
|
|
485
|
+
finish({ success: false, status: "startup_failed", stderr: error.message });
|
|
486
|
+
});
|
|
487
|
+
child.on("close", () => {
|
|
488
|
+
finish({ success: false, status: stdout ? "protocol_mismatch" : "startup_failed", responsePreview: stdout.slice(0, 500), stderr: stderr.slice(0, 500) });
|
|
489
|
+
});
|
|
490
|
+
child.stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function spawnCodexWorker(
|
|
495
|
+
plan: WorkerCommandPlan,
|
|
496
|
+
prompt: string,
|
|
497
|
+
timeoutMs: number,
|
|
498
|
+
spawnContext: { executablePath: string; env: NodeJS.ProcessEnv },
|
|
499
|
+
onStdoutChunk: (chunk: string) => void,
|
|
500
|
+
signal?: AbortSignal
|
|
501
|
+
): Promise<{ exitCode: number; stderr: string; timedOut: boolean }> {
|
|
502
|
+
return await new Promise((resolve) => {
|
|
503
|
+
const child = spawn(spawnContext.executablePath, plan.args, {
|
|
504
|
+
cwd: plan.cwd,
|
|
505
|
+
env: spawnContext.env,
|
|
506
|
+
shell: false,
|
|
507
|
+
detached: true,
|
|
508
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
509
|
+
});
|
|
510
|
+
let stderr = "";
|
|
511
|
+
let settled = false;
|
|
512
|
+
let timedOut = false;
|
|
513
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
514
|
+
const appendStderr = (message: string): void => {
|
|
515
|
+
stderr = `${stderr}${stderr ? "\n" : ""}${message}`;
|
|
516
|
+
};
|
|
517
|
+
const finish = (result: { exitCode: number; stderr: string; timedOut: boolean }): void => {
|
|
518
|
+
if (!settled) {
|
|
519
|
+
settled = true;
|
|
520
|
+
clearTimeout(timer);
|
|
521
|
+
if (killTimer) {
|
|
522
|
+
clearTimeout(killTimer);
|
|
523
|
+
}
|
|
524
|
+
resolve(result);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const timer = setTimeout(() => {
|
|
528
|
+
if (!settled) {
|
|
529
|
+
timedOut = true;
|
|
530
|
+
signalProcessTree(child.pid, child, "SIGTERM");
|
|
531
|
+
killTimer = setTimeout(() => {
|
|
532
|
+
signalProcessTree(child.pid, child, "SIGKILL");
|
|
533
|
+
finish({ exitCode: 124, stderr, timedOut: true });
|
|
534
|
+
}, 1_000);
|
|
535
|
+
}
|
|
536
|
+
}, timeoutMs);
|
|
537
|
+
const abort = (): void => {
|
|
538
|
+
if (!settled) {
|
|
539
|
+
timedOut = true;
|
|
540
|
+
signalProcessTree(child.pid, child, "SIGTERM");
|
|
541
|
+
killTimer = setTimeout(() => {
|
|
542
|
+
signalProcessTree(child.pid, child, "SIGKILL");
|
|
543
|
+
finish({ exitCode: 130, stderr, timedOut: true });
|
|
544
|
+
}, 1_000);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
548
|
+
child.stdout.setEncoding("utf8");
|
|
549
|
+
child.stderr.setEncoding("utf8");
|
|
550
|
+
child.stdout.on("data", (chunk: string) => {
|
|
551
|
+
onStdoutChunk(chunk);
|
|
552
|
+
});
|
|
553
|
+
child.stderr.on("data", (chunk: string) => {
|
|
554
|
+
stderr += chunk;
|
|
555
|
+
});
|
|
556
|
+
child.on("error", (error) => {
|
|
557
|
+
appendStderr(error.message);
|
|
558
|
+
finish({ exitCode: 1, stderr, timedOut: false });
|
|
559
|
+
});
|
|
560
|
+
child.on("close", (code, closeSignal) => {
|
|
561
|
+
signal?.removeEventListener("abort", abort);
|
|
562
|
+
finish({ exitCode: code ?? 1, stderr, timedOut: timedOut || closeSignal === "SIGTERM" });
|
|
563
|
+
});
|
|
564
|
+
child.stdin.on("error", (error) => {
|
|
565
|
+
if (!isClosedWorkerStdinError(error)) {
|
|
566
|
+
appendStderr(error.message);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
try {
|
|
570
|
+
child.stdin.end(prompt);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (!isClosedWorkerStdinError(error)) {
|
|
573
|
+
appendStderr(errorMessage(error));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function isClosedWorkerStdinError(error: unknown): boolean {
|
|
580
|
+
const code = typeof error === "object" && error !== null && "code" in error
|
|
581
|
+
? String((error as { code?: unknown }).code)
|
|
582
|
+
: "";
|
|
583
|
+
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function errorMessage(error: unknown): string {
|
|
587
|
+
return error instanceof Error ? error.message : String(error);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function createWorkerSpawnContext(
|
|
591
|
+
repoRoot: string,
|
|
592
|
+
workerId: string,
|
|
593
|
+
executable: string
|
|
594
|
+
): { executablePath: string; env: NodeJS.ProcessEnv } {
|
|
595
|
+
const originalPath = process.env.PATH ?? "";
|
|
596
|
+
const executablePath = resolveExecutable(executable, originalPath);
|
|
597
|
+
const binDir = join(repoRoot, ".agent-loop", "worker-policy-bin", workerId);
|
|
598
|
+
mkdirSync(binDir, { recursive: true });
|
|
599
|
+
writeShim(join(binDir, "git"), gitShim(resolveOptionalExecutable("git", originalPath)));
|
|
600
|
+
writeShim(join(binDir, "gh"), ghShim(resolveOptionalExecutable("gh", originalPath)));
|
|
601
|
+
writeShim(join(binDir, "codex"), codexShim(resolveOptionalExecutable("codex", originalPath)));
|
|
602
|
+
return {
|
|
603
|
+
executablePath,
|
|
604
|
+
env: {
|
|
605
|
+
...process.env,
|
|
606
|
+
PATH: `${binDir}:${originalPath}`,
|
|
607
|
+
AGENT_LOOP_WORKER_POLICY: "1"
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function writeShim(path: string, content: string): void {
|
|
613
|
+
writeFileSync(path, content);
|
|
614
|
+
chmodSync(path, 0o755);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function gitShim(realPath: string | undefined): string {
|
|
618
|
+
return `#!/bin/sh
|
|
619
|
+
cmd="$1"
|
|
620
|
+
while [ "$cmd" = "-c" ] || [ "$cmd" = "-C" ]; do
|
|
621
|
+
shift 2 || exit 126
|
|
622
|
+
cmd="$1"
|
|
623
|
+
done
|
|
624
|
+
case "$cmd" in
|
|
625
|
+
commit|push|rebase|reset|clean|merge) echo "agent-loop worker policy denied git side effect" >&2; exit 126 ;;
|
|
626
|
+
esac
|
|
627
|
+
${execLine(realPath)}
|
|
628
|
+
`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function ghShim(realPath: string | undefined): string {
|
|
632
|
+
return `#!/bin/sh
|
|
633
|
+
case "$1 $2" in
|
|
634
|
+
"repo delete"|"pr create"|"pr ready"|"pr merge"|"pr close"|"pr comment") echo "agent-loop worker policy denied gh side effect" >&2; exit 126 ;;
|
|
635
|
+
esac
|
|
636
|
+
${execLine(realPath)}
|
|
637
|
+
`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function codexShim(realPath: string | undefined): string {
|
|
641
|
+
return `#!/bin/sh
|
|
642
|
+
for arg in "$@"; do
|
|
643
|
+
case "$arg" in
|
|
644
|
+
--dangerously-bypass-approvals-and-sandbox|danger-full-access) echo "agent-loop worker policy denied danger sandbox" >&2; exit 126 ;;
|
|
645
|
+
esac
|
|
646
|
+
done
|
|
647
|
+
if [ "$1" = "exec" ]; then
|
|
648
|
+
echo "agent-loop worker policy denied nested codex exec" >&2
|
|
649
|
+
exit 126
|
|
650
|
+
fi
|
|
651
|
+
${execLine(realPath)}
|
|
652
|
+
`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function execLine(realPath: string | undefined): string {
|
|
656
|
+
return realPath ? `exec ${shellQuote(realPath)} "$@"` : "echo \"command unavailable\" >&2; exit 127";
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function shellQuote(value: string): string {
|
|
660
|
+
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function resolveExecutable(file: string, pathValue: string): string {
|
|
664
|
+
const resolved = resolveOptionalExecutable(file, pathValue);
|
|
665
|
+
if (!resolved) {
|
|
666
|
+
throw new AgentLoopError("required_tool_unavailable", `Required executable not found: ${file}`, {
|
|
667
|
+
details: { file },
|
|
668
|
+
exitCode: 2
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return resolved;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function resolveOptionalExecutable(file: string, pathValue: string): string | undefined {
|
|
675
|
+
try {
|
|
676
|
+
return execFileSync("which", [file], {
|
|
677
|
+
encoding: "utf8",
|
|
678
|
+
env: { ...process.env, PATH: pathValue },
|
|
679
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
680
|
+
}).trim();
|
|
681
|
+
} catch {
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function signalProcessTree(
|
|
687
|
+
pid: number | undefined,
|
|
688
|
+
child: ReturnType<typeof spawn>,
|
|
689
|
+
signal: NodeJS.Signals
|
|
690
|
+
): void {
|
|
691
|
+
try {
|
|
692
|
+
if (pid) {
|
|
693
|
+
process.kill(-pid, signal);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// Fall back to the direct child when process-group signaling is unavailable.
|
|
698
|
+
}
|
|
699
|
+
child.kill(signal);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function parseWorkerResult(path: string): { ok: true; result: WorkerResult } | { ok: false; error: string } {
|
|
703
|
+
if (!existsSync(path)) {
|
|
704
|
+
return { ok: false, error: `Missing worker final output: ${path}` };
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
708
|
+
return isWorkerResult(parsed)
|
|
709
|
+
? { ok: true, result: parsed }
|
|
710
|
+
: { ok: false, error: "Worker final output failed structural validation." };
|
|
711
|
+
} catch (error) {
|
|
712
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function isWorkerResult(value: unknown): value is WorkerResult {
|
|
717
|
+
if (!isRecord(value)) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
return typeof value.ok === "boolean" &&
|
|
721
|
+
typeof value.summary === "string" &&
|
|
722
|
+
isStringArray(value.changedFiles) &&
|
|
723
|
+
Array.isArray(value.commandsRun) &&
|
|
724
|
+
value.commandsRun.every(isCommandRun) &&
|
|
725
|
+
isStringArray(value.testsRun) &&
|
|
726
|
+
isRecord(value.gitnexus) &&
|
|
727
|
+
typeof value.gitnexus.impactRun === "boolean" &&
|
|
728
|
+
typeof value.gitnexus.detectChangesRun === "boolean" &&
|
|
729
|
+
Array.isArray(value.outOfScope) &&
|
|
730
|
+
value.outOfScope.every(isOutOfScope) &&
|
|
731
|
+
isStringArray(value.followUps);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function isCommandRun(value: unknown): value is { command: string; exitCode: number } {
|
|
735
|
+
return isRecord(value) && typeof value.command === "string" && Number.isInteger(value.exitCode);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function isOutOfScope(value: unknown): value is { item: string; reason: string } {
|
|
739
|
+
return isRecord(value) && typeof value.item === "string" && typeof value.reason === "string";
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function persistExistingResult(
|
|
743
|
+
repoRoot: string,
|
|
744
|
+
storage: AgentLoopStorage,
|
|
745
|
+
runId: string,
|
|
746
|
+
path: string,
|
|
747
|
+
name: string
|
|
748
|
+
): ArtifactRecord {
|
|
749
|
+
const content = readFileSync(path);
|
|
750
|
+
const record = {
|
|
751
|
+
id: randomUUID(),
|
|
752
|
+
runId,
|
|
753
|
+
kind: "worker-result" as const,
|
|
754
|
+
name,
|
|
755
|
+
path,
|
|
756
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
757
|
+
createdAt: new Date().toISOString()
|
|
758
|
+
};
|
|
759
|
+
storage.insertArtifact(record);
|
|
760
|
+
return record;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function clearOrRejectRunningWorker(storage: AgentLoopStorage, workerTimeoutMs: number): void {
|
|
764
|
+
const running = storage.getRunningWorker();
|
|
765
|
+
if (!running) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const ageMs = Date.now() - Date.parse(running.startedAt);
|
|
769
|
+
if (Number.isFinite(ageMs) && ageMs > workerTimeoutMs) {
|
|
770
|
+
storage.updateWorker(running.id, {
|
|
771
|
+
status: "failed",
|
|
772
|
+
completedAt: new Date().toISOString(),
|
|
773
|
+
exitCode: 124,
|
|
774
|
+
error: "Stale running worker cleaned before spawning a new worker."
|
|
775
|
+
});
|
|
776
|
+
storage.appendEvent({
|
|
777
|
+
runId: running.runId,
|
|
778
|
+
kind: "stale_worker_cleaned",
|
|
779
|
+
message: `Cleaned stale running worker ${running.id}.`,
|
|
780
|
+
payload: { workerId: running.id, ageMs, workerTimeoutMs }
|
|
781
|
+
});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
throw new AgentLoopError("worker_already_running", "Another worker is already running.", {
|
|
785
|
+
details: { workerId: running.id, runId: running.runId, startedAt: running.startedAt },
|
|
786
|
+
exitCode: 2
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function optionalAllowedPaths(type: WorkerType, config: AgentLoopConfig, state: AgentLoopState): { allowedPaths?: string[] } {
|
|
791
|
+
const allowedPaths = resolveWorkerPolicy({ config, state, workerType: type }).allowedPaths;
|
|
792
|
+
return allowedPaths ? { allowedPaths } : {};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function genericScopeGateDetails(config: AgentLoopConfig, state: AgentLoopState, scope: unknown): Record<string, unknown> {
|
|
796
|
+
return {
|
|
797
|
+
...(typeof scope === "object" && scope !== null && !Array.isArray(scope) ? scope as Record<string, unknown> : {}),
|
|
798
|
+
loopShape: config.loopShape,
|
|
799
|
+
workflowProfile: config.workflowProfile,
|
|
800
|
+
state,
|
|
801
|
+
allowedNextStates: ["PLAN_WORK", "STOPPED"],
|
|
802
|
+
defaultNextState: "PLAN_WORK",
|
|
803
|
+
requiredPayload: { nextState: "PLAN_WORK", source: "ui" }
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isStringArray(value: unknown): value is string[] {
|
|
808
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
809
|
+
}
|