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,828 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { writeArtifact } from "./artifacts.js";
|
|
3
|
+
import { CommandRunner } from "./command-runner.js";
|
|
4
|
+
import { isRecord, loadConfig, statePath } from "./config.js";
|
|
5
|
+
import { AgentLoopError, isGateCode } from "./errors.js";
|
|
6
|
+
import { WORKER_FAILURE_RECOVERED_DECISION } from "./gate-recovery.js";
|
|
7
|
+
import { GENERIC_LOOP_SHAPE, PR_LOOP_SHAPE, resolveLoopShape, type LoopShape } from "./loop-shapes.js";
|
|
8
|
+
import { executeGenericLifecycleStep, executeGenericPreWorkerStep } from "./generic-lifecycle.js";
|
|
9
|
+
import { executePrLifecycleStep } from "./pr-lifecycle.js";
|
|
10
|
+
import { resolvePrSelection } from "./pr-selector.js";
|
|
11
|
+
import { getDeliveryWorkItem, type DeliveryWorkItem } from "./delivery-work-item.js";
|
|
12
|
+
import { applyProfileConfig, resolveProfile } from "./profiles.js";
|
|
13
|
+
import { SqliteAgentLoopStorage } from "./storage.js";
|
|
14
|
+
import { executeWorker } from "./worker.js";
|
|
15
|
+
import type { GitHubPullRequest } from "./github.js";
|
|
16
|
+
import type { AgentLoopConfig, AgentLoopGateKind, AgentLoopRun, WorkerRun, WorkerStatus } from "./types.js";
|
|
17
|
+
import type {
|
|
18
|
+
AgentLoopState,
|
|
19
|
+
AgentLoopTrigger,
|
|
20
|
+
ArtifactRecord,
|
|
21
|
+
CommandPlan,
|
|
22
|
+
RealitySnapshot,
|
|
23
|
+
StateMachineResult,
|
|
24
|
+
StateTransition,
|
|
25
|
+
TransitionGuard
|
|
26
|
+
} from "./state-types.js";
|
|
27
|
+
|
|
28
|
+
export const LOOP_STATES: AgentLoopState[] = [...new Set([...PR_LOOP_SHAPE.states, ...GENERIC_LOOP_SHAPE.states])];
|
|
29
|
+
|
|
30
|
+
export const TERMINAL_STATES: AgentLoopState[] = [...new Set([...PR_LOOP_SHAPE.terminalStates, ...GENERIC_LOOP_SHAPE.terminalStates])];
|
|
31
|
+
|
|
32
|
+
/** Single declarative transition table used by PR B and extended by later PRs. */
|
|
33
|
+
export const TRANSITIONS: StateTransition[] = [...PR_LOOP_SHAPE.transitions, ...GENERIC_LOOP_SHAPE.transitions];
|
|
34
|
+
|
|
35
|
+
/** Validate transition reachability and terminal-state coverage. */
|
|
36
|
+
export function validateTransitionTable(): string[] {
|
|
37
|
+
const errors: string[] = [];
|
|
38
|
+
for (const shape of [PR_LOOP_SHAPE, GENERIC_LOOP_SHAPE]) {
|
|
39
|
+
const states = new Set(shape.states);
|
|
40
|
+
for (const transition of shape.transitions) {
|
|
41
|
+
if (!states.has(transition.from)) {
|
|
42
|
+
errors.push(`${shape.id}: unknown from state: ${transition.from}`);
|
|
43
|
+
}
|
|
44
|
+
if (!states.has(transition.to)) {
|
|
45
|
+
errors.push(`${shape.id}: unknown to state: ${transition.to}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const state of shape.states) {
|
|
49
|
+
const terminal = shape.terminalStates.includes(state);
|
|
50
|
+
const hasExit = shape.transitions.some((transition) => transition.from === state);
|
|
51
|
+
if (!terminal && !hasExit) {
|
|
52
|
+
errors.push(`${shape.id}: state has no exit: ${state}`);
|
|
53
|
+
}
|
|
54
|
+
if (state !== "STOPPED" && state !== "COMPLETE" && !shape.transitions.some((transition) => transition.from === state && transition.to === "STOPPED" && transition.trigger === "stop")) {
|
|
55
|
+
errors.push(`${shape.id}: state has no stop transition: ${state}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const reachable = new Set<AgentLoopState>([shape.initialState]);
|
|
59
|
+
let changed = true;
|
|
60
|
+
while (changed) {
|
|
61
|
+
changed = false;
|
|
62
|
+
for (const transition of shape.transitions) {
|
|
63
|
+
if (reachable.has(transition.from) && !reachable.has(transition.to)) {
|
|
64
|
+
reachable.add(transition.to);
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const state of shape.states.filter((item) => !shape.terminalStates.includes(item))) {
|
|
70
|
+
if (!reachable.has(state)) {
|
|
71
|
+
errors.push(`${shape.id}: state is unreachable: ${state}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return errors;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Return command plans for a state; dry-run records these without execution. */
|
|
79
|
+
export function planState(state: AgentLoopState, repoRoot: string): CommandPlan[] {
|
|
80
|
+
if (state === "SYNC_MAIN") {
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
id: "git-status",
|
|
84
|
+
file: "git",
|
|
85
|
+
args: ["status", "--short", "--branch"],
|
|
86
|
+
cwd: repoRoot,
|
|
87
|
+
purpose: "Inspect current worktree before loop progress."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "git-branch",
|
|
91
|
+
file: "git",
|
|
92
|
+
args: ["branch", "--show-current"],
|
|
93
|
+
cwd: repoRoot,
|
|
94
|
+
purpose: "Record branch for resume reality checks."
|
|
95
|
+
}
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
if (state === "DISCOVER_PROGRESS") {
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
id: "git-work-tree",
|
|
102
|
+
file: "git",
|
|
103
|
+
args: ["rev-parse", "--is-inside-work-tree"],
|
|
104
|
+
cwd: repoRoot,
|
|
105
|
+
purpose: "Confirm repo context before selecting next PR."
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Start or continue a dry-run state machine until one step or the next gate. */
|
|
113
|
+
export async function runStateMachine(options: {
|
|
114
|
+
repoRoot: string;
|
|
115
|
+
dryRun: boolean;
|
|
116
|
+
untilGate: boolean;
|
|
117
|
+
singleStep?: boolean;
|
|
118
|
+
signal?: AbortSignal | undefined;
|
|
119
|
+
pullRequests?: GitHubPullRequest[];
|
|
120
|
+
}): Promise<StateMachineResult> {
|
|
121
|
+
assertTransitionTable();
|
|
122
|
+
const storage = new SqliteAgentLoopStorage(statePath(options.repoRoot));
|
|
123
|
+
try {
|
|
124
|
+
const configResult = tryLoadConfig(options.repoRoot);
|
|
125
|
+
const shape = configResult.ok ? resolveLoopShape(applyProfileConfig(configResult.config).loopShape) : PR_LOOP_SHAPE;
|
|
126
|
+
const run = ensureRun(storage, options.repoRoot, shape);
|
|
127
|
+
if (!configResult.ok) {
|
|
128
|
+
return blockRun(storage, run, "needs_repo_init", configResult.error.message, configResult.error.details);
|
|
129
|
+
}
|
|
130
|
+
const effectiveConfig = applyProfileConfig(configResult.config);
|
|
131
|
+
auditProfileSelection(storage, run, effectiveConfig);
|
|
132
|
+
let currentRun = run;
|
|
133
|
+
const openGate = storage.listGates(run.id).find((item) => item.status === "open");
|
|
134
|
+
if (run.status === "BLOCKED" && openGate) {
|
|
135
|
+
const workItem = getDeliveryWorkItem(storage, currentRun.id);
|
|
136
|
+
if (shape.id === "pr-loop" && !options.dryRun && openGate.kind === "ambiguous_next_pr" && !resolvePrSelection(options.repoRoot, effectiveConfig, selectionOptions(options.pullRequests, options.dryRun, workItem)).ambiguous) {
|
|
137
|
+
storage.resolveOpenGatesByKind("ambiguous_next_pr", { scope: "run", runId: run.id });
|
|
138
|
+
currentRun = storage.updateRunStatus(run.id, run.version, "RUNNING", { currentState: normalizeState(run.currentState, shape) });
|
|
139
|
+
storage.appendEvent({
|
|
140
|
+
runId: currentRun.id,
|
|
141
|
+
kind: "gate_recovery",
|
|
142
|
+
message: "Resolved ambiguous_next_pr after PR selector found a unique target.",
|
|
143
|
+
payload: { gate: "ambiguous_next_pr", source: "state_machine" }
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
runId: run.id,
|
|
149
|
+
status: "BLOCKED",
|
|
150
|
+
currentState: normalizeState(run.currentState, shape),
|
|
151
|
+
transitions: [],
|
|
152
|
+
gate: { kind: openGate.kind, message: openGate.message, ...(openGate.details === undefined ? {} : { details: openGate.details }) },
|
|
153
|
+
artifacts: []
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const workerGate = blockRunForTerminalWorker(storage, currentRun);
|
|
158
|
+
if (workerGate) {
|
|
159
|
+
return workerGate;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const transitions: Array<{ from: AgentLoopState; to: AgentLoopState }> = [];
|
|
163
|
+
const artifacts: ArtifactRecord[] = [];
|
|
164
|
+
let current = normalizeState(currentRun.currentState, shape);
|
|
165
|
+
if (shape.id === "generic-loop" && currentRun.status === "READY" && current === "COMPLETE") {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
runId: currentRun.id,
|
|
169
|
+
status: "READY",
|
|
170
|
+
currentState: "COMPLETE",
|
|
171
|
+
transitions,
|
|
172
|
+
artifacts
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const maxSteps = options.untilGate ? 10 : 1;
|
|
176
|
+
|
|
177
|
+
for (let index = 0; index < maxSteps; index += 1) {
|
|
178
|
+
if (shape.id === "pr-loop" && current === "SELECT_NEXT_PR") {
|
|
179
|
+
const workItem = getDeliveryWorkItem(storage, currentRun.id);
|
|
180
|
+
const selection = resolvePrSelection(options.repoRoot, effectiveConfig, selectionOptions(options.pullRequests, options.dryRun, workItem));
|
|
181
|
+
if (selection.ambiguous) {
|
|
182
|
+
return blockRun(
|
|
183
|
+
storage,
|
|
184
|
+
currentRun,
|
|
185
|
+
"ambiguous_next_pr",
|
|
186
|
+
"Could not uniquely identify the next PR from the configured plans directory.",
|
|
187
|
+
{
|
|
188
|
+
plansDir: effectiveConfig.plansDir,
|
|
189
|
+
reason: selection.reason,
|
|
190
|
+
candidates: selection.candidates,
|
|
191
|
+
evidence: selection.evidence
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (selection.mode === "current_pr") {
|
|
196
|
+
if (!options.dryRun) {
|
|
197
|
+
storage.upsertPrLink({
|
|
198
|
+
runId: currentRun.id,
|
|
199
|
+
branch: selection.pr.headRefName,
|
|
200
|
+
prNumber: selection.pr.number,
|
|
201
|
+
url: selection.pr.url,
|
|
202
|
+
headRef: selection.pr.headRefName,
|
|
203
|
+
baseRef: selection.pr.baseRefName,
|
|
204
|
+
state: selection.pr.state,
|
|
205
|
+
draft: selection.pr.isDraft
|
|
206
|
+
});
|
|
207
|
+
storage.appendDecision({
|
|
208
|
+
runId: currentRun.id,
|
|
209
|
+
kind: "pr_reused",
|
|
210
|
+
message: `Selected existing PR #${selection.pr.number} for ${selection.item.id}.`,
|
|
211
|
+
details: { branch: selection.pr.headRefName, spec: selection.item.file }
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const nextState: AgentLoopState = "WAIT_REVIEW_OR_CI";
|
|
215
|
+
transitions.push({ from: current, to: nextState });
|
|
216
|
+
if (!options.dryRun) {
|
|
217
|
+
currentRun = storage.updateRunStatus(currentRun.id, currentRun.version, "RUNNING", {
|
|
218
|
+
currentState: nextState,
|
|
219
|
+
branch: selection.pr.headRefName,
|
|
220
|
+
worktreeClean: true
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
currentRun = { ...currentRun, currentState: nextState, branch: selection.pr.headRefName, worktreeClean: true };
|
|
224
|
+
}
|
|
225
|
+
storage.appendEvent({
|
|
226
|
+
runId: currentRun.id,
|
|
227
|
+
kind: "state_transition",
|
|
228
|
+
message: `${current} -> ${nextState}`,
|
|
229
|
+
stateBefore: current,
|
|
230
|
+
stateAfter: nextState,
|
|
231
|
+
payload: { selectedPr: selection.item.id, prNumber: selection.pr.number, branch: selection.pr.headRefName, explicitWorkItem: workItem }
|
|
232
|
+
});
|
|
233
|
+
current = nextState;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const next = nextTransition(shape, current, "step", "progress", { includeTerminal: shape.id === "generic-loop" });
|
|
238
|
+
if (!next) {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
const preWorkerGenericLifecycle = shape.id === "generic-loop"
|
|
242
|
+
? executeGenericPreWorkerStep({ storage, run: currentRun, state: current, dryRun: options.dryRun })
|
|
243
|
+
: {};
|
|
244
|
+
if (preWorkerGenericLifecycle.transitionGuard) {
|
|
245
|
+
const transition = nextTransition(shape, current, "step", "progress", {
|
|
246
|
+
guard: preWorkerGenericLifecycle.transitionGuard,
|
|
247
|
+
includeTerminal: true
|
|
248
|
+
});
|
|
249
|
+
if (!transition) {
|
|
250
|
+
throw new AgentLoopError("storage_error", `No generic transition from ${current} for guard ${preWorkerGenericLifecycle.transitionGuard}.`);
|
|
251
|
+
}
|
|
252
|
+
const nextState = transition.to;
|
|
253
|
+
const nextStatus = preWorkerGenericLifecycle.status ?? (nextState === "STOPPED" ? "STOPPED" : "RUNNING");
|
|
254
|
+
if (!options.dryRun) {
|
|
255
|
+
currentRun = storage.updateRunStatus(currentRun.id, currentRun.version, nextStatus, { currentState: nextState });
|
|
256
|
+
} else {
|
|
257
|
+
currentRun = { ...currentRun, currentState: nextState, status: nextStatus };
|
|
258
|
+
}
|
|
259
|
+
storage.appendEvent({
|
|
260
|
+
runId: currentRun.id,
|
|
261
|
+
kind: "state_transition",
|
|
262
|
+
message: `${current} -> ${nextState}`,
|
|
263
|
+
stateBefore: current,
|
|
264
|
+
stateAfter: nextState,
|
|
265
|
+
payload: { dryRun: options.dryRun, loopShape: shape.id, genericLifecycle: preWorkerGenericLifecycle }
|
|
266
|
+
});
|
|
267
|
+
transitions.push({ from: current, to: nextState });
|
|
268
|
+
current = nextState;
|
|
269
|
+
if (options.singleStep || shape.terminalStates.includes(current)) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const plans = planState(current, options.repoRoot);
|
|
275
|
+
const artifact = writeArtifact(
|
|
276
|
+
options.repoRoot,
|
|
277
|
+
storage,
|
|
278
|
+
currentRun.id,
|
|
279
|
+
"dry-run-plan",
|
|
280
|
+
`${current.toLowerCase()}.json`,
|
|
281
|
+
`${JSON.stringify({ state: current, dryRun: options.dryRun, plans }, null, 2)}\n`
|
|
282
|
+
);
|
|
283
|
+
artifacts.push(artifact);
|
|
284
|
+
const commandResults = await applyCommandPlans(
|
|
285
|
+
options.repoRoot,
|
|
286
|
+
storage,
|
|
287
|
+
currentRun.id,
|
|
288
|
+
effectiveConfig,
|
|
289
|
+
plans,
|
|
290
|
+
options.dryRun,
|
|
291
|
+
options.signal
|
|
292
|
+
);
|
|
293
|
+
let workerResult: Awaited<ReturnType<typeof executeWorker>> | undefined;
|
|
294
|
+
let lifecycle: Awaited<ReturnType<typeof executePrLifecycleStep>> | undefined;
|
|
295
|
+
let genericLifecycle: Awaited<ReturnType<typeof executeGenericLifecycleStep>> | undefined;
|
|
296
|
+
try {
|
|
297
|
+
const workerType = shape.defaultRoleForState(current);
|
|
298
|
+
if (workerType) {
|
|
299
|
+
workerResult = await executeWorker({
|
|
300
|
+
repoRoot: options.repoRoot,
|
|
301
|
+
storage,
|
|
302
|
+
run: currentRun,
|
|
303
|
+
config: effectiveConfig,
|
|
304
|
+
state: current,
|
|
305
|
+
type: workerType,
|
|
306
|
+
dryRun: options.dryRun,
|
|
307
|
+
signal: options.signal
|
|
308
|
+
});
|
|
309
|
+
artifacts.push(...workerResult.artifacts);
|
|
310
|
+
}
|
|
311
|
+
lifecycle = shape.id === "pr-loop" && !options.dryRun
|
|
312
|
+
? await executePrLifecycleStep({
|
|
313
|
+
repoRoot: options.repoRoot,
|
|
314
|
+
storage,
|
|
315
|
+
run: currentRun,
|
|
316
|
+
config: effectiveConfig,
|
|
317
|
+
state: current,
|
|
318
|
+
signal: options.signal
|
|
319
|
+
})
|
|
320
|
+
: undefined;
|
|
321
|
+
genericLifecycle = shape.id === "generic-loop"
|
|
322
|
+
? await executeGenericLifecycleStep({
|
|
323
|
+
repoRoot: options.repoRoot,
|
|
324
|
+
storage,
|
|
325
|
+
run: currentRun,
|
|
326
|
+
config: effectiveConfig,
|
|
327
|
+
state: current,
|
|
328
|
+
dryRun: options.dryRun,
|
|
329
|
+
...(workerResult?.result ? { workerResult: workerResult.result } : {})
|
|
330
|
+
})
|
|
331
|
+
: undefined;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof AgentLoopError && isGateCode(error.code)) {
|
|
334
|
+
return blockRun(storage, currentRun, error.code, error.message, error.details);
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
const selectedGenericTransition = shape.id === "generic-loop"
|
|
339
|
+
? nextTransition(shape, current, "step", "progress", {
|
|
340
|
+
guard: genericLifecycle?.transitionGuard ?? "always",
|
|
341
|
+
includeTerminal: true
|
|
342
|
+
})
|
|
343
|
+
: undefined;
|
|
344
|
+
if (shape.id === "generic-loop" && !selectedGenericTransition) {
|
|
345
|
+
throw new AgentLoopError("storage_error", `No generic transition from ${current} for guard ${genericLifecycle?.transitionGuard ?? "always"}.`);
|
|
346
|
+
}
|
|
347
|
+
const nextState = lifecycle?.nextState ?? selectedGenericTransition?.to ?? next.to;
|
|
348
|
+
artifacts.push(...(genericLifecycle?.artifacts ?? []));
|
|
349
|
+
const nextStatus = genericLifecycle?.status ?? (shape.id === "generic-loop" && nextState === "COMPLETE" ? "READY" : nextState === "STOPPED" ? "STOPPED" : "RUNNING");
|
|
350
|
+
const updateOptions: {
|
|
351
|
+
currentState: AgentLoopState;
|
|
352
|
+
branch?: string;
|
|
353
|
+
worktreeClean?: boolean;
|
|
354
|
+
} = { currentState: nextState };
|
|
355
|
+
if (lifecycle?.branch !== undefined) {
|
|
356
|
+
updateOptions.branch = lifecycle.branch;
|
|
357
|
+
}
|
|
358
|
+
if (lifecycle?.worktreeClean !== undefined) {
|
|
359
|
+
updateOptions.worktreeClean = lifecycle.worktreeClean;
|
|
360
|
+
}
|
|
361
|
+
if (!options.dryRun) {
|
|
362
|
+
currentRun = storage.updateRunStatus(currentRun.id, currentRun.version, nextStatus, updateOptions);
|
|
363
|
+
} else {
|
|
364
|
+
currentRun = { ...currentRun, currentState: nextState, status: nextStatus };
|
|
365
|
+
}
|
|
366
|
+
if (shape.id === "generic-loop" && nextState === "COMPLETE" && !options.dryRun) {
|
|
367
|
+
storage.appendDecision({
|
|
368
|
+
runId: currentRun.id,
|
|
369
|
+
kind: "generic_loop_completed",
|
|
370
|
+
message: "Generic loop completed.",
|
|
371
|
+
details: { loopShape: shape.id, workflowProfile: effectiveConfig.workflowProfile }
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
storage.appendEvent({
|
|
375
|
+
runId: currentRun.id,
|
|
376
|
+
kind: "state_transition",
|
|
377
|
+
message: `${current} -> ${nextState}`,
|
|
378
|
+
stateBefore: current,
|
|
379
|
+
stateAfter: nextState,
|
|
380
|
+
payload: { dryRun: options.dryRun, loopShape: shape.id, plans, commandResults, worker: workerResult, lifecycle, genericLifecycle },
|
|
381
|
+
artifactIds: [artifact.id, ...(workerResult?.artifacts.map((item) => item.id) ?? []), ...(genericLifecycle?.artifacts?.map((item) => item.id) ?? [])]
|
|
382
|
+
});
|
|
383
|
+
transitions.push({ from: current, to: nextState });
|
|
384
|
+
current = nextState;
|
|
385
|
+
if (options.singleStep) {
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
if (shape.terminalStates.includes(current)) {
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result: StateMachineResult = {
|
|
394
|
+
ok: true,
|
|
395
|
+
runId: currentRun.id,
|
|
396
|
+
status: currentRun.status,
|
|
397
|
+
currentState: normalizeState(currentRun.currentState, shape),
|
|
398
|
+
transitions,
|
|
399
|
+
artifacts
|
|
400
|
+
};
|
|
401
|
+
return result;
|
|
402
|
+
} finally {
|
|
403
|
+
storage.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Resume the latest run after validating branch and clean-worktree reality. */
|
|
408
|
+
export async function resumeStateMachine(repoRoot: string): Promise<StateMachineResult> {
|
|
409
|
+
assertTransitionTable();
|
|
410
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
411
|
+
try {
|
|
412
|
+
const run = storage.getCurrentRun();
|
|
413
|
+
if (!run) {
|
|
414
|
+
return await runStateMachine({ repoRoot, dryRun: true, untilGate: false });
|
|
415
|
+
}
|
|
416
|
+
const configResult = tryLoadConfig(repoRoot);
|
|
417
|
+
const shape = configResult.ok ? resolveLoopShape(applyProfileConfig(configResult.config).loopShape) : shapeForStoredState(run.currentState);
|
|
418
|
+
if (shape.id === "generic-loop" && run.status === "READY" && normalizeState(run.currentState, shape) === "COMPLETE") {
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
runId: run.id,
|
|
422
|
+
status: "READY",
|
|
423
|
+
currentState: "COMPLETE",
|
|
424
|
+
transitions: [],
|
|
425
|
+
artifacts: []
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (run.status === "STOPPED") {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
runId: run.id,
|
|
432
|
+
status: run.status,
|
|
433
|
+
currentState: normalizeState(run.currentState, shape),
|
|
434
|
+
transitions: [],
|
|
435
|
+
artifacts: []
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
let currentRun = run;
|
|
439
|
+
if (currentRun.status === "BLOCKED") {
|
|
440
|
+
const openGate = storage.listGates(currentRun.id).find((gate) => gate.status === "open");
|
|
441
|
+
if (openGate?.kind === "ambiguous_next_pr") {
|
|
442
|
+
const effectiveConfig = configResult.ok ? applyProfileConfig(configResult.config) : undefined;
|
|
443
|
+
const workItem = effectiveConfig ? getDeliveryWorkItem(storage, currentRun.id) : undefined;
|
|
444
|
+
if (effectiveConfig && !resolvePrSelection(repoRoot, effectiveConfig, { githubRequired: true, ...(workItem ? { workItem } : {}) }).ambiguous) {
|
|
445
|
+
storage.resolveOpenGatesByKind("ambiguous_next_pr", { scope: "run", runId: currentRun.id });
|
|
446
|
+
currentRun = storage.updateRunStatus(currentRun.id, currentRun.version, "RUNNING", { currentState: normalizeState(currentRun.currentState, resolveLoopShape(effectiveConfig.loopShape)) });
|
|
447
|
+
storage.appendEvent({
|
|
448
|
+
runId: currentRun.id,
|
|
449
|
+
kind: "gate_recovery",
|
|
450
|
+
message: "Resolved ambiguous_next_pr before resume after PR selector found a unique target.",
|
|
451
|
+
payload: { gate: "ambiguous_next_pr", source: "resume" }
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (storage.listGates(currentRun.id).some((gate) => gate.status === "open")) {
|
|
456
|
+
return {
|
|
457
|
+
ok: false,
|
|
458
|
+
runId: currentRun.id,
|
|
459
|
+
status: currentRun.status,
|
|
460
|
+
currentState: normalizeState(currentRun.currentState),
|
|
461
|
+
transitions: [],
|
|
462
|
+
artifacts: []
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (currentRun.status === "BLOCKED") {
|
|
466
|
+
currentRun = storage.updateRunStatus(currentRun.id, currentRun.version, "RUNNING");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const workerGate = blockRunForTerminalWorker(storage, currentRun);
|
|
470
|
+
if (workerGate) {
|
|
471
|
+
return workerGate;
|
|
472
|
+
}
|
|
473
|
+
const reality = readReality(repoRoot);
|
|
474
|
+
if (
|
|
475
|
+
(currentRun.branch && currentRun.branch !== reality.branch) ||
|
|
476
|
+
(currentRun.worktreeClean !== undefined && currentRun.worktreeClean !== reality.worktreeClean)
|
|
477
|
+
) {
|
|
478
|
+
return blockRun(storage, currentRun, "dirty_unowned_worktree", "Reality check failed before resume.", {
|
|
479
|
+
expected: { branch: currentRun.branch, worktreeClean: currentRun.worktreeClean },
|
|
480
|
+
actual: reality
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return await runStateMachine({ repoRoot, dryRun: false, untilGate: false, singleStep: true });
|
|
484
|
+
} finally {
|
|
485
|
+
storage.close();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Stop the current run without deleting state history. */
|
|
490
|
+
export function stopStateMachine(repoRoot: string): StateMachineResult {
|
|
491
|
+
assertTransitionTable();
|
|
492
|
+
const storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
493
|
+
try {
|
|
494
|
+
let run = storage.getCurrentRun();
|
|
495
|
+
if (!run) {
|
|
496
|
+
return {
|
|
497
|
+
ok: true,
|
|
498
|
+
status: "STOPPED",
|
|
499
|
+
currentState: "STOPPED",
|
|
500
|
+
transitions: [],
|
|
501
|
+
artifacts: []
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const shape = shapeForStoredState(run.currentState);
|
|
505
|
+
const stateBefore = normalizeState(run.currentState, shape);
|
|
506
|
+
const stopTransition = nextTransition(shape, stateBefore, "stop", "terminal");
|
|
507
|
+
if (!stopTransition) {
|
|
508
|
+
throw new AgentLoopError("storage_error", `No stop transition for state ${normalizeState(run.currentState, shape)}.`);
|
|
509
|
+
}
|
|
510
|
+
storage.resolveOpenGates(run.id);
|
|
511
|
+
const runningWorker = storage.getRunningWorker();
|
|
512
|
+
if (runningWorker) {
|
|
513
|
+
storage.updateWorker(runningWorker.id, {
|
|
514
|
+
status: "failed",
|
|
515
|
+
completedAt: new Date().toISOString(),
|
|
516
|
+
exitCode: 130,
|
|
517
|
+
error: "Stopped by supervisor."
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
let stopped: AgentLoopRun | undefined;
|
|
521
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
522
|
+
try {
|
|
523
|
+
stopped = storage.updateRunStatus(run.id, run.version, "STOPPED", {
|
|
524
|
+
currentState: "STOPPED",
|
|
525
|
+
stoppedAt: new Date().toISOString()
|
|
526
|
+
});
|
|
527
|
+
break;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (!(error instanceof AgentLoopError) || error.code !== "version_conflict" || attempt === 2) {
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
run = storage.getCurrentRun() ?? run;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!stopped) {
|
|
536
|
+
throw new AgentLoopError("storage_error", "Run stop did not complete.");
|
|
537
|
+
}
|
|
538
|
+
storage.appendEvent({
|
|
539
|
+
runId: stopped.id,
|
|
540
|
+
kind: "run_stopped",
|
|
541
|
+
message: "Run stopped by CLI.",
|
|
542
|
+
stateBefore,
|
|
543
|
+
stateAfter: "STOPPED"
|
|
544
|
+
});
|
|
545
|
+
return {
|
|
546
|
+
ok: true,
|
|
547
|
+
runId: stopped.id,
|
|
548
|
+
status: "STOPPED",
|
|
549
|
+
currentState: "STOPPED",
|
|
550
|
+
transitions: [{ from: stopTransition.from, to: "STOPPED" }],
|
|
551
|
+
artifacts: []
|
|
552
|
+
};
|
|
553
|
+
} finally {
|
|
554
|
+
storage.close();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function ensureRun(
|
|
559
|
+
storage: SqliteAgentLoopStorage,
|
|
560
|
+
repoRoot: string,
|
|
561
|
+
shape: LoopShape
|
|
562
|
+
): AgentLoopRun {
|
|
563
|
+
const existing = storage.getCurrentRun();
|
|
564
|
+
if (existing && existing.status !== "STOPPED") {
|
|
565
|
+
return existing;
|
|
566
|
+
}
|
|
567
|
+
const reality = readReality(repoRoot);
|
|
568
|
+
return storage.createRun("RUNNING", {
|
|
569
|
+
currentState: shape.initialState,
|
|
570
|
+
branch: reality.branch,
|
|
571
|
+
worktreeClean: reality.worktreeClean
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function auditProfileSelection(
|
|
576
|
+
storage: SqliteAgentLoopStorage,
|
|
577
|
+
run: AgentLoopRun,
|
|
578
|
+
config: AgentLoopConfig
|
|
579
|
+
): void {
|
|
580
|
+
if (storage.listDecisions(run.id).some((decision) => decision.kind === "profile_selected")) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const profile = resolveProfile(config, normalizeState(run.currentState, resolveLoopShape(config.loopShape)));
|
|
584
|
+
const details = {
|
|
585
|
+
loopShape: profile.loopShape,
|
|
586
|
+
workflowProfile: profile.workflowProfile,
|
|
587
|
+
roleProfile: profile.roleProfile,
|
|
588
|
+
currentRole: profile.currentRole,
|
|
589
|
+
roleMapping: profile.roleMapping,
|
|
590
|
+
autonomyBoundary: profile.autonomyBoundary,
|
|
591
|
+
validationPosture: profile.validationPosture,
|
|
592
|
+
source: "config_or_default"
|
|
593
|
+
};
|
|
594
|
+
storage.appendDecision({
|
|
595
|
+
runId: run.id,
|
|
596
|
+
kind: "profile_selected",
|
|
597
|
+
message: `Selected workflow profile ${profile.workflowProfile} for ${profile.loopShape}.`,
|
|
598
|
+
details
|
|
599
|
+
});
|
|
600
|
+
storage.appendEvent({
|
|
601
|
+
runId: run.id,
|
|
602
|
+
kind: "profile_selected",
|
|
603
|
+
message: `Selected workflow profile ${profile.workflowProfile}.`,
|
|
604
|
+
payload: details
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function blockRun(
|
|
609
|
+
storage: SqliteAgentLoopStorage,
|
|
610
|
+
run: AgentLoopRun,
|
|
611
|
+
kind: AgentLoopGateKind,
|
|
612
|
+
message: string,
|
|
613
|
+
details?: unknown
|
|
614
|
+
): StateMachineResult {
|
|
615
|
+
const stateBefore = stateFromGateDetails(details) ?? normalizeState(run.currentState, shapeForStoredState(run.currentState));
|
|
616
|
+
const blocked = storage.updateRunStatus(run.id, run.version, "BLOCKED", {
|
|
617
|
+
currentState: stateBefore
|
|
618
|
+
});
|
|
619
|
+
storage.writeGate({ runId: blocked.id, kind, message, details });
|
|
620
|
+
storage.appendEvent({
|
|
621
|
+
runId: blocked.id,
|
|
622
|
+
kind: "gate_opened",
|
|
623
|
+
message,
|
|
624
|
+
stateBefore,
|
|
625
|
+
stateAfter: "BLOCKED",
|
|
626
|
+
payload: { gate: kind, details }
|
|
627
|
+
});
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
runId: blocked.id,
|
|
631
|
+
status: "BLOCKED",
|
|
632
|
+
currentState: stateBefore,
|
|
633
|
+
transitions: [{ from: stateBefore, to: "BLOCKED" }],
|
|
634
|
+
gate: { kind, message, ...(details === undefined ? {} : { details }) },
|
|
635
|
+
artifacts: []
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function stateFromGateDetails(details: unknown): AgentLoopState | undefined {
|
|
640
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) return undefined;
|
|
641
|
+
const state = (details as { state?: unknown }).state;
|
|
642
|
+
return typeof state === "string" ? normalizeState(state, shapeForStoredState(state)) : undefined;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Convert a terminal worker failure on a still-running run into a visible gate. */
|
|
646
|
+
export function blockRunForTerminalWorker(
|
|
647
|
+
storage: SqliteAgentLoopStorage,
|
|
648
|
+
run: AgentLoopRun
|
|
649
|
+
): StateMachineResult | undefined {
|
|
650
|
+
if (run.status !== "RUNNING" || storage.listGates(run.id).some((gate) => gate.status === "open")) {
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
const recoveredWorkerIds = collectRecoveredWorkerIds(storage, run.id);
|
|
654
|
+
const workers = storage.listWorkers(run.id, 20);
|
|
655
|
+
if (workers.length === 0 || workers.some((item) => item.status === "running")) {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
const latestStartedAt = workers.reduce((latest, item) => item.startedAt > latest ? item.startedAt : latest, workers[0]?.startedAt ?? "");
|
|
659
|
+
const latestWorkers = workers.filter((item) => item.startedAt === latestStartedAt);
|
|
660
|
+
if (latestWorkers.some((item) => item.status === "succeeded")) {
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
const worker = latestWorkers.find((item) => gateForTerminalWorker(item) !== undefined && !recoveredWorkerIds.has(item.id));
|
|
664
|
+
if (!worker) return undefined;
|
|
665
|
+
const gate = gateForTerminalWorker(worker);
|
|
666
|
+
if (!gate) {
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
return blockRun(storage, run, gate, messageForTerminalWorker(worker), detailsForTerminalWorker(worker));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Worker ids an operator has explicitly marked obsolete via recovery. Reconcile must not
|
|
674
|
+
* re-open a gate for these workers; doing so would silently hide an active failure and
|
|
675
|
+
* trap the run. A fresh worker attempt (new id) is not in this set until recovered again.
|
|
676
|
+
*/
|
|
677
|
+
function collectRecoveredWorkerIds(storage: SqliteAgentLoopStorage, runId: string): Set<string> {
|
|
678
|
+
const ids = new Set<string>();
|
|
679
|
+
for (const decision of storage.listDecisions(runId)) {
|
|
680
|
+
if (decision.kind !== WORKER_FAILURE_RECOVERED_DECISION || !isRecord(decision.details)) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const workerIds = decision.details.workerIds;
|
|
684
|
+
if (Array.isArray(workerIds)) {
|
|
685
|
+
for (const id of workerIds) {
|
|
686
|
+
if (typeof id === "string") {
|
|
687
|
+
ids.add(id);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return ids;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function gateForTerminalWorker(worker: WorkerRun): AgentLoopGateKind | undefined {
|
|
696
|
+
const gates: Partial<Record<WorkerStatus, AgentLoopGateKind>> = {
|
|
697
|
+
failed: "worker_failed",
|
|
698
|
+
invalid_output: "worker_output_invalid",
|
|
699
|
+
timed_out: "worker_timeout"
|
|
700
|
+
};
|
|
701
|
+
return gates[worker.status];
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function messageForTerminalWorker(worker: WorkerRun): string {
|
|
705
|
+
if (worker.status === "invalid_output") {
|
|
706
|
+
return "Worker output did not match schema.";
|
|
707
|
+
}
|
|
708
|
+
if (worker.status === "timed_out") {
|
|
709
|
+
return "Codex worker timed out.";
|
|
710
|
+
}
|
|
711
|
+
return worker.error ?? "Codex worker failed.";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function detailsForTerminalWorker(worker: WorkerRun): Record<string, unknown> {
|
|
715
|
+
return {
|
|
716
|
+
workerId: worker.id,
|
|
717
|
+
workerType: worker.type,
|
|
718
|
+
attempt: worker.attempt,
|
|
719
|
+
...(worker.exitCode === undefined ? {} : { exitCode: worker.exitCode }),
|
|
720
|
+
...(worker.error === undefined ? {} : { error: worker.error }),
|
|
721
|
+
...(worker.threadId === undefined ? {} : { threadId: worker.threadId })
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function selectionOptions(
|
|
726
|
+
pullRequests: GitHubPullRequest[] | undefined,
|
|
727
|
+
dryRun: boolean,
|
|
728
|
+
workItem?: DeliveryWorkItem
|
|
729
|
+
): { pullRequests?: GitHubPullRequest[]; githubRequired: boolean; workItem?: DeliveryWorkItem } {
|
|
730
|
+
return {
|
|
731
|
+
...(pullRequests === undefined ? {} : { pullRequests }),
|
|
732
|
+
githubRequired: !dryRun,
|
|
733
|
+
...(workItem ? { workItem } : {})
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function assertTransitionTable(): void {
|
|
738
|
+
const errors = validateTransitionTable();
|
|
739
|
+
if (errors.length > 0) {
|
|
740
|
+
throw new AgentLoopError("storage_error", "State transition table is invalid.", {
|
|
741
|
+
details: { errors }
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function nextTransition(
|
|
747
|
+
shape: LoopShape,
|
|
748
|
+
state: AgentLoopState,
|
|
749
|
+
trigger: AgentLoopTrigger,
|
|
750
|
+
mode: "progress" | "terminal",
|
|
751
|
+
options: { guard?: TransitionGuard; includeTerminal?: boolean } = {}
|
|
752
|
+
): StateTransition | undefined {
|
|
753
|
+
return shape.transitions.find((transition) => {
|
|
754
|
+
if (transition.from !== state || transition.trigger !== trigger) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
if (options.guard !== undefined && transition.guard !== options.guard) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
const terminal = shape.terminalStates.includes(transition.to);
|
|
761
|
+
if (options.includeTerminal && mode === "progress") {
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
return mode === "terminal" ? terminal : !terminal;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function tryLoadConfig(
|
|
769
|
+
repoRoot: string
|
|
770
|
+
):
|
|
771
|
+
| { ok: true; config: ReturnType<typeof loadConfig>["config"] }
|
|
772
|
+
| { ok: false; error: AgentLoopError } {
|
|
773
|
+
try {
|
|
774
|
+
return { ok: true, config: loadConfig(repoRoot).config };
|
|
775
|
+
} catch (error) {
|
|
776
|
+
if (error instanceof AgentLoopError) {
|
|
777
|
+
return { ok: false, error };
|
|
778
|
+
}
|
|
779
|
+
throw error;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function normalizeState(value: string | undefined, shape: LoopShape = PR_LOOP_SHAPE): AgentLoopState {
|
|
784
|
+
return shape.states.includes(value as AgentLoopState) ? (value as AgentLoopState) : shape.initialState;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function shapeForStoredState(value: string | undefined): LoopShape {
|
|
788
|
+
return value && GENERIC_LOOP_SHAPE.states.includes(value as AgentLoopState) ? GENERIC_LOOP_SHAPE : PR_LOOP_SHAPE;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function readReality(repoRoot: string): RealitySnapshot {
|
|
792
|
+
try {
|
|
793
|
+
return {
|
|
794
|
+
branch: execFileSync("git", ["branch", "--show-current"], {
|
|
795
|
+
cwd: repoRoot,
|
|
796
|
+
encoding: "utf8",
|
|
797
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
798
|
+
}).trim(),
|
|
799
|
+
worktreeClean:
|
|
800
|
+
execFileSync("git", ["status", "--short"], {
|
|
801
|
+
cwd: repoRoot,
|
|
802
|
+
encoding: "utf8",
|
|
803
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
804
|
+
}).trim().length === 0
|
|
805
|
+
};
|
|
806
|
+
} catch (error) {
|
|
807
|
+
throw new AgentLoopError("not_git_repo", "Could not read git reality for this repository.", {
|
|
808
|
+
details: { cause: error instanceof Error ? error.message : String(error) }
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function applyCommandPlans(
|
|
814
|
+
repoRoot: string,
|
|
815
|
+
storage: SqliteAgentLoopStorage,
|
|
816
|
+
runId: string,
|
|
817
|
+
config: ReturnType<typeof loadConfig>["config"],
|
|
818
|
+
plans: CommandPlan[],
|
|
819
|
+
dryRun: boolean,
|
|
820
|
+
signal?: AbortSignal
|
|
821
|
+
): Promise<unknown[]> {
|
|
822
|
+
const runner = new CommandRunner({ repoRoot, storage, runId, config, signal });
|
|
823
|
+
const results = [];
|
|
824
|
+
for (const plan of plans) {
|
|
825
|
+
results.push(await runner.run(plan, dryRun));
|
|
826
|
+
}
|
|
827
|
+
return results;
|
|
828
|
+
}
|