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,1479 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { readArtifact } from "./artifacts.js";
|
|
5
|
+
import { describeAutonomyPosture, evaluateMergeReadiness, type MergeReadiness } from "./autonomy-policy.js";
|
|
6
|
+
import { readConfigForEdit, saveConfigEdit } from "./config-editor.js";
|
|
7
|
+
import { isRecord, loadConfig, statePath } from "./config.js";
|
|
8
|
+
import { AgentLoopError, toErrorPayload } from "./errors.js";
|
|
9
|
+
import { recoverBlockedRun, TERMINAL_WORKER_GATE_KINDS } from "./gate-recovery.js";
|
|
10
|
+
import { detectHappy } from "./happy.js";
|
|
11
|
+
import { inspectHookCapture } from "./hook-capture.js";
|
|
12
|
+
import { resolveLoopShape } from "./loop-shapes.js";
|
|
13
|
+
import { deriveNotifications, type LoopNotification } from "./notification-feed.js";
|
|
14
|
+
import { resolvePrSelection, type PrSelection } from "./pr-selector.js";
|
|
15
|
+
import { applyProfileConfig, resolveProfile, workflowStages } from "./profiles.js";
|
|
16
|
+
import { redactSecrets } from "./redaction.js";
|
|
17
|
+
import { blockRunForTerminalWorker, runStateMachine, resumeStateMachine, stopStateMachine } from "./state-machine.js";
|
|
18
|
+
import { SqliteAgentLoopStorage } from "./storage.js";
|
|
19
|
+
import { executeWorker } from "./worker.js";
|
|
20
|
+
import { getDeliveryWorkItem, WORKFLOW_STAGE_EVIDENCE_KIND } from "./delivery-work-item.js";
|
|
21
|
+
import { appendWorkflowEvidence, deriveWorkflowBoard, selectWorkflowBoardRun } from "./workflow-board.js";
|
|
22
|
+
import type { AgentLoopConfig, AgentLoopEvent, AgentLoopGate, AgentLoopRun, AgentTimelineQuery, AgentTimelineEntry, GateDecisionInput, WorkerRun, WorkerType } from "./types.js";
|
|
23
|
+
import type { AgentLoopState } from "./state-types.js";
|
|
24
|
+
|
|
25
|
+
const NOTIFICATION_EVENT_LIMIT = 100;
|
|
26
|
+
const HISTORICAL_EVENT_SCAN_LIMIT = 100_000;
|
|
27
|
+
const HISTORICAL_EVENT_KIND = "historical_gate_marked_handled";
|
|
28
|
+
const HISTORICAL_REEVALUATED_EVENT_KIND = "historical_gate_re_evaluated";
|
|
29
|
+
|
|
30
|
+
type HistoricalGateReevaluationResult =
|
|
31
|
+
| "still_historical"
|
|
32
|
+
| "overridden_by_current_reality"
|
|
33
|
+
| "active_again"
|
|
34
|
+
| "manually_handled";
|
|
35
|
+
|
|
36
|
+
export interface McpControllerOptions {
|
|
37
|
+
repoRoot: string;
|
|
38
|
+
startRun?: (repoRoot: string, runId: string) => boolean | void;
|
|
39
|
+
mcpToken?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface McpResult<T = unknown> {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
data?: T;
|
|
45
|
+
error?: ReturnType<typeof toErrorPayload>;
|
|
46
|
+
gate?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shared control-plane API used by MCP tools and CLI gate commands. */
|
|
50
|
+
export class McpController {
|
|
51
|
+
constructor(private readonly options: McpControllerOptions) {}
|
|
52
|
+
|
|
53
|
+
loopStatus(): McpResult {
|
|
54
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
55
|
+
this.reconcileTerminalWorker(storage);
|
|
56
|
+
const current = storage.getCurrentStatus();
|
|
57
|
+
return ok({ ...current, nextAction: nextAction(current.status, current.gate?.kind) });
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
loopMissionControl(): McpResult {
|
|
62
|
+
return this.withConfig((config) => this.withStorage((storage) => {
|
|
63
|
+
this.reconcileTerminalWorker(storage);
|
|
64
|
+
const snapshot = storage.readTransaction(() => {
|
|
65
|
+
const current = storage.getCurrentStatus();
|
|
66
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
67
|
+
const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
|
|
68
|
+
const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
|
|
69
|
+
const runs = storage.listRuns(20);
|
|
70
|
+
const dismissedHistoricalGateIds = historicalGateHandledIds(historicalEvents);
|
|
71
|
+
const gates = annotateGates({
|
|
72
|
+
gates: storage.listGates(),
|
|
73
|
+
current,
|
|
74
|
+
...(run ? { run } : {}),
|
|
75
|
+
runs,
|
|
76
|
+
dismissedHistoricalGateIds
|
|
77
|
+
});
|
|
78
|
+
const activeGates = gates.filter((gate) => gate.activity === "active");
|
|
79
|
+
const currentRunGates = gates.filter((gate) => gate.activity === "active" && (gate.runId === run?.id || gate.runId === undefined));
|
|
80
|
+
const missionCurrent = currentForMissionControl(current, gates);
|
|
81
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
82
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
83
|
+
const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
|
|
84
|
+
const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
|
|
85
|
+
const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
|
|
86
|
+
const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
|
|
87
|
+
const deliveryWorkItem = getDeliveryWorkItem(storage, run?.id);
|
|
88
|
+
const selection = shape.id === "pr-loop" ? resolvePrSelection(this.options.repoRoot, effectiveConfig, {
|
|
89
|
+
...(deliveryWorkItem ? { workItem: deliveryWorkItem } : {})
|
|
90
|
+
}) : undefined;
|
|
91
|
+
const workers = annotateWorkers({
|
|
92
|
+
workers: storage.listWorkers(undefined, 20),
|
|
93
|
+
gates,
|
|
94
|
+
...(run ? { run } : {})
|
|
95
|
+
});
|
|
96
|
+
const activeWorkers = workers.filter((worker) => worker.activity === "active");
|
|
97
|
+
const timeline = storage.listAgentTimeline({
|
|
98
|
+
limit: 20,
|
|
99
|
+
...(run ? { runId: run.id } : {})
|
|
100
|
+
}).entries;
|
|
101
|
+
const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates: currentRunGates, decisions, runChecks }) : undefined;
|
|
102
|
+
const missionMergeReadiness = mergeReadiness ? mergeReadinessForMissionDisplay(mergeReadiness, events) : undefined;
|
|
103
|
+
const dismissedIds = notificationDismissedIds(events);
|
|
104
|
+
const notifications = deriveNotifications({ config: effectiveConfig, events, gates: activeGates, timelineEntries: timeline, workers: activeWorkers, ...(mergeReadiness ? { mergeReadiness } : {}), ...(run ? { runId: run.id } : {}), now: new Date(), dismissedIds });
|
|
105
|
+
return {
|
|
106
|
+
current: { ...missionCurrent, nextAction: nextAction(missionCurrent.status, missionCurrent.gate?.kind) },
|
|
107
|
+
gates,
|
|
108
|
+
pr: shape.id === "pr-loop" && run ? storage.getPrLink(run.id) : undefined,
|
|
109
|
+
ci: shape.id === "pr-loop" ? ci : [],
|
|
110
|
+
reviewComments: shape.id === "pr-loop" ? reviewComments : [],
|
|
111
|
+
workers,
|
|
112
|
+
artifacts: run ? storage.listArtifacts(run.id) : [],
|
|
113
|
+
events,
|
|
114
|
+
decisions,
|
|
115
|
+
timelineSummary: buildTimelineSummary({
|
|
116
|
+
timeline,
|
|
117
|
+
workers,
|
|
118
|
+
...(run ? { currentRunId: run.id } : {}),
|
|
119
|
+
listWorkerEvents: (workerId) => storage.listWorkerEvents(workerId)
|
|
120
|
+
}),
|
|
121
|
+
autonomy: describeAutonomyPosture(effectiveConfig),
|
|
122
|
+
mergeReadiness: missionMergeReadiness,
|
|
123
|
+
notifications,
|
|
124
|
+
profile: resolveProfile(effectiveConfig, run?.currentState as AgentLoopState | undefined),
|
|
125
|
+
plan: selection?.plan,
|
|
126
|
+
selection: selection ? selectionSummary(selection) : genericSelectionSummary(effectiveConfig),
|
|
127
|
+
recoveryWarnings: recoveryWarnings(missionCurrent.gate?.kind, gates, workers)
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
return ok(snapshot);
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
loopWorkflowBoard(input: { runId?: string } = {}): McpResult {
|
|
135
|
+
return this.withConfig((config) => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
|
|
136
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
137
|
+
const run = selectWorkflowBoardRun(storage, input.runId);
|
|
138
|
+
const currentRun = storage.getCurrentRun();
|
|
139
|
+
const deliveryWorkItem = getDeliveryWorkItem(storage, run?.id);
|
|
140
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
141
|
+
const gates = run ? storage.listGates(run.id) : storage.listGates();
|
|
142
|
+
const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
|
|
143
|
+
const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
|
|
144
|
+
const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
|
|
145
|
+
const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
|
|
146
|
+
const mergeReadiness = shape.id === "pr-loop" && run
|
|
147
|
+
? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks })
|
|
148
|
+
: undefined;
|
|
149
|
+
return ok(deriveWorkflowBoard({
|
|
150
|
+
config: effectiveConfig,
|
|
151
|
+
...(run ? { run } : {}),
|
|
152
|
+
...(currentRun ? { currentRun } : {}),
|
|
153
|
+
gates,
|
|
154
|
+
events: storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT).filter((event) => !run || event.runId === run.id || event.runId === undefined),
|
|
155
|
+
workers: run ? storage.listWorkers(run.id, 20) : [],
|
|
156
|
+
artifacts: run ? storage.listArtifacts(run.id) : [],
|
|
157
|
+
pr: run && shape.id === "pr-loop" ? storage.getPrLink(run.id) : undefined,
|
|
158
|
+
ci,
|
|
159
|
+
reviewComments,
|
|
160
|
+
decisions,
|
|
161
|
+
runChecks,
|
|
162
|
+
...(deliveryWorkItem ? { deliveryWorkItem } : {}),
|
|
163
|
+
...(mergeReadiness ? { mergeReadiness } : {}),
|
|
164
|
+
hookCapture: inspectHookCapture(this.options.repoRoot)
|
|
165
|
+
}));
|
|
166
|
+
})));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
loopAppendWorkflowEvidence(body: unknown, token?: string): McpResult {
|
|
170
|
+
const auth = this.requireToken(token);
|
|
171
|
+
if (auth) return auth;
|
|
172
|
+
if (!isRecord(body)) {
|
|
173
|
+
return fail(new AgentLoopError("invalid_config", "Workflow evidence append requires a JSON object."));
|
|
174
|
+
}
|
|
175
|
+
return this.withConfig(() => this.withStorage((storage) => ok(appendWorkflowEvidence(storage, {
|
|
176
|
+
runId: typeof body.runId === "string" ? body.runId : undefined,
|
|
177
|
+
stageId: typeof body.stageId === "string" ? body.stageId : undefined,
|
|
178
|
+
substageId: typeof body.substageId === "string" ? body.substageId : undefined,
|
|
179
|
+
summary: typeof body.summary === "string" ? body.summary : undefined,
|
|
180
|
+
evidenceRefIds: body.evidenceRefIds,
|
|
181
|
+
artifactIds: body.artifactIds,
|
|
182
|
+
actor: typeof body.actor === "string" ? body.actor : undefined,
|
|
183
|
+
status: typeof body.status === "string" ? body.status : undefined,
|
|
184
|
+
source: typeof body.source === "string" ? body.source : "dashboard",
|
|
185
|
+
review: body.review
|
|
186
|
+
}))));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
loopAgentTimeline(query: AgentTimelineQuery = {}): McpResult {
|
|
190
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => ok(storage.listAgentTimeline(query))));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
loopObserve(limit = 20): McpResult {
|
|
194
|
+
return this.withConfig((config) => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
|
|
195
|
+
const current = storage.getCurrentStatus();
|
|
196
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
197
|
+
const timeline = storage.listAgentTimeline({
|
|
198
|
+
limit,
|
|
199
|
+
...(run ? { runId: run.id } : {})
|
|
200
|
+
});
|
|
201
|
+
return ok({
|
|
202
|
+
dashboard: dashboardInfo(config),
|
|
203
|
+
happy: detectHappy(),
|
|
204
|
+
current: { ...current, nextAction: nextAction(current.status, current.gate?.kind) },
|
|
205
|
+
timeline
|
|
206
|
+
});
|
|
207
|
+
})));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
loopNextAction(): McpResult {
|
|
211
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
212
|
+
this.reconcileTerminalWorker(storage);
|
|
213
|
+
const current = storage.getCurrentStatus();
|
|
214
|
+
return ok({ nextAction: nextAction(current.status, current.gate?.kind), current });
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
loopStep(token?: string): Promise<McpResult> {
|
|
219
|
+
const auth = this.requireToken(token);
|
|
220
|
+
if (auth) return Promise.resolve(auth);
|
|
221
|
+
return this.withConfigAsync(async () => ok(await runStateMachine({
|
|
222
|
+
repoRoot: this.options.repoRoot,
|
|
223
|
+
dryRun: false,
|
|
224
|
+
untilGate: false,
|
|
225
|
+
singleStep: true
|
|
226
|
+
})));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
loopResume(token?: string): Promise<McpResult> {
|
|
230
|
+
const auth = this.requireToken(token);
|
|
231
|
+
if (auth) return Promise.resolve(auth);
|
|
232
|
+
return this.withConfigAsync(async () => ok(await resumeStateMachine(this.options.repoRoot)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
loopStop(token?: string): McpResult {
|
|
236
|
+
const auth = this.requireToken(token);
|
|
237
|
+
if (auth) return auth;
|
|
238
|
+
return this.withConfig(() => ok(stopStateMachine(this.options.repoRoot)));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
loopRunUntilGate(token?: string): McpResult {
|
|
242
|
+
const auth = this.requireToken(token);
|
|
243
|
+
if (auth) return auth;
|
|
244
|
+
return this.withConfig((config) => this.withStorage((storage) => {
|
|
245
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
246
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
247
|
+
const { run, created } = storage.getOrCreateActiveRun({ currentState: shape.initialState });
|
|
248
|
+
const workerGate = blockRunForTerminalWorker(storage, run);
|
|
249
|
+
if (workerGate) {
|
|
250
|
+
return ok({
|
|
251
|
+
runId: workerGate.runId,
|
|
252
|
+
status: workerGate.status,
|
|
253
|
+
alreadyRunning: !created,
|
|
254
|
+
reconciled: true,
|
|
255
|
+
gate: workerGate.gate
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (created) {
|
|
259
|
+
storage.appendEvent({
|
|
260
|
+
runId: run.id,
|
|
261
|
+
kind: "mcp_run_until_gate_started",
|
|
262
|
+
message: "MCP requested background run until gate."
|
|
263
|
+
});
|
|
264
|
+
const started = (this.options.startRun ?? startBackgroundRun)(this.options.repoRoot, run.id);
|
|
265
|
+
if (started === false) {
|
|
266
|
+
storage.updateRunStatus(run.id, run.version, "BLOCKED", { currentState: run.currentState ?? shape.initialState });
|
|
267
|
+
storage.writeGate({
|
|
268
|
+
runId: run.id,
|
|
269
|
+
kind: "required_tool_unavailable",
|
|
270
|
+
message: "Could not start background agent-loop run."
|
|
271
|
+
});
|
|
272
|
+
const gate = storage.listGates(run.id).find((item) => item.status === "open");
|
|
273
|
+
return fail(new AgentLoopError("required_tool_unavailable", "Could not start background run.", {
|
|
274
|
+
details: { runId: run.id, gate }
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return ok({ runId: run.id, status: "RUNNING", alreadyRunning: !created });
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
loopListGates(): McpResult {
|
|
283
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
284
|
+
this.reconcileTerminalWorker(storage);
|
|
285
|
+
return ok({ gates: annotatedGatesSnapshot(storage) });
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
loopExplainGate(gateId: string): McpResult {
|
|
290
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => {
|
|
291
|
+
const gate = annotatedGateSnapshot(storage, gateId);
|
|
292
|
+
if (!gate) {
|
|
293
|
+
throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
|
|
294
|
+
}
|
|
295
|
+
return ok({ gate, nextAction: nextAction("BLOCKED", gate.kind) });
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
loopApproveGate(gateId: string, input: string | GateDecisionInput, token?: string): McpResult {
|
|
300
|
+
return this.decideGate(gateId, "approved", input, token);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
loopRejectGate(gateId: string, input: string | GateDecisionInput, token?: string): McpResult {
|
|
304
|
+
return this.decideGate(gateId, "rejected", input, token);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
loopListRuns(limit?: number): McpResult {
|
|
308
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => ok({ runs: storage.listRuns(limit) })));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
loopListWorkers(input?: number | { limit?: number; workerId?: string; includeEvents?: boolean }): McpResult {
|
|
312
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => {
|
|
313
|
+
const run = storage.getCurrentRun();
|
|
314
|
+
const limit = typeof input === "number" ? input : input?.limit;
|
|
315
|
+
const workerId = typeof input === "number" ? undefined : input?.workerId;
|
|
316
|
+
const includeEvents = typeof input === "number" ? false : input?.includeEvents === true;
|
|
317
|
+
const workers = storage
|
|
318
|
+
.listWorkers(run?.id, limit ?? 50)
|
|
319
|
+
.filter((worker) => !workerId || worker.id === workerId);
|
|
320
|
+
if (!includeEvents) {
|
|
321
|
+
return ok({ workers });
|
|
322
|
+
}
|
|
323
|
+
const eventsByWorker = Object.fromEntries(workers.map((worker) => [
|
|
324
|
+
worker.id,
|
|
325
|
+
storage.listAgentTimeline({ workerId: worker.id, sources: ["worker_event"], limit: 50 }).entries
|
|
326
|
+
]));
|
|
327
|
+
return ok({ workers, eventsByWorker });
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
loopListEvents(sinceSeq?: number, limit?: number): McpResult {
|
|
332
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => {
|
|
333
|
+
const options = {
|
|
334
|
+
...(sinceSeq === undefined ? {} : { sinceSeq }),
|
|
335
|
+
limit: limit ?? 50
|
|
336
|
+
};
|
|
337
|
+
return ok({ events: storage.listEvents(options) });
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
loopReadArtifact(artifactId: string): McpResult {
|
|
342
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => {
|
|
343
|
+
const artifactRoot = resolve(this.options.repoRoot, ".agent-loop", "artifacts");
|
|
344
|
+
const record = storage.getArtifact(artifactId);
|
|
345
|
+
assertArtifactPathInsideRoot(artifactRoot, record.path, artifactId);
|
|
346
|
+
if (isSensitiveArtifactKind(record.kind)) {
|
|
347
|
+
throw new AgentLoopError("policy_violation", `Artifact kind ${record.kind} is not readable through the dashboard API.`, {
|
|
348
|
+
details: { artifactId, kind: record.kind }
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const artifact = readArtifact(storage, artifactId);
|
|
352
|
+
return ok({
|
|
353
|
+
record: artifact.record,
|
|
354
|
+
contentBase64: artifact.content.toString("base64")
|
|
355
|
+
});
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
loopGetPrStatus(): McpResult {
|
|
360
|
+
return this.withCurrentRun((storage, run) => ok({ pr: storage.getPrLink(run.id) }));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
loopGetCiStatus(): McpResult {
|
|
364
|
+
return this.withCurrentRun((storage, run) => ok({ checks: storage.listCiChecks(run.id) }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
loopGetReviewComments(): McpResult {
|
|
368
|
+
return this.withCurrentRun((storage, run) => ok({ comments: storage.listReviewComments(run.id) }));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
loopListArtifacts(): McpResult {
|
|
372
|
+
return this.withCurrentRun((storage, run) => ok({ artifacts: storage.listArtifacts(run.id) }));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
loopDashboardMeta(): McpResult {
|
|
376
|
+
return this.withConfig((config) => {
|
|
377
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
378
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
379
|
+
return ok({
|
|
380
|
+
appName: "HOLO-Codex",
|
|
381
|
+
surface: "dashboard",
|
|
382
|
+
targetRepo: {
|
|
383
|
+
root: this.options.repoRoot,
|
|
384
|
+
repoId: config.repoId
|
|
385
|
+
},
|
|
386
|
+
pollingMs: 3000,
|
|
387
|
+
autonomy: describeAutonomyPosture(config),
|
|
388
|
+
pages: [
|
|
389
|
+
"Mission Control",
|
|
390
|
+
"Plan Navigator",
|
|
391
|
+
"Policy Config",
|
|
392
|
+
"Dry-run Preview",
|
|
393
|
+
"Notifications",
|
|
394
|
+
"Gate Center",
|
|
395
|
+
...(shape.id === "pr-loop" ? ["PR Inbox"] : []),
|
|
396
|
+
"Worker Runs",
|
|
397
|
+
"Scope Guard",
|
|
398
|
+
"Event Ledger",
|
|
399
|
+
"Artifact Diff Viewer",
|
|
400
|
+
"Recovery Center"
|
|
401
|
+
]
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
loopPlanNavigator(): McpResult {
|
|
407
|
+
return this.withConfig((config) => {
|
|
408
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
409
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
410
|
+
if (shape.id !== "pr-loop") {
|
|
411
|
+
return ok({ plan: undefined, selection: genericSelectionSummary(effectiveConfig) });
|
|
412
|
+
}
|
|
413
|
+
const selection = resolvePrSelection(this.options.repoRoot, effectiveConfig);
|
|
414
|
+
return ok({ plan: selection.plan, selection: selectionSummary(selection) });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
loopPolicyConfig(): McpResult {
|
|
419
|
+
return this.withConfig(() => ok(readConfigForEdit(this.options.repoRoot)));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
loopSavePolicyConfig(body: unknown, token?: string): McpResult {
|
|
423
|
+
const auth = this.requireToken(token);
|
|
424
|
+
if (auth) return auth;
|
|
425
|
+
if (!isRecord(body) || !isRecord(body.nextConfig) || typeof body.expectedHash !== "string") {
|
|
426
|
+
return fail(new AgentLoopError("invalid_config", "Policy config save requires nextConfig and expectedHash."));
|
|
427
|
+
}
|
|
428
|
+
const expectedHash = body.expectedHash;
|
|
429
|
+
return this.withConfig(() => ok(saveConfigEdit(this.options.repoRoot, {
|
|
430
|
+
nextConfig: body.nextConfig as never,
|
|
431
|
+
expectedHash,
|
|
432
|
+
...(typeof body.note === "string" ? { note: body.note } : {}),
|
|
433
|
+
...(typeof body.confirmationToken === "string" ? { confirmationToken: body.confirmationToken } : {})
|
|
434
|
+
})));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
loopDryRunPreview(): McpResult {
|
|
438
|
+
return this.withConfig((config) => this.withStorage((storage) => {
|
|
439
|
+
this.reconcileTerminalWorker(storage);
|
|
440
|
+
const current = storage.getCurrentStatus();
|
|
441
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
442
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
443
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
444
|
+
const profile = resolveProfile(effectiveConfig, run?.currentState as AgentLoopState | undefined);
|
|
445
|
+
const selection = shape.id === "pr-loop" ? resolvePrSelection(this.options.repoRoot, effectiveConfig) : undefined;
|
|
446
|
+
const gates = run ? storage.listGates(run.id) : storage.listGates();
|
|
447
|
+
const openGates = gates.filter((gate) => gate.status === "open");
|
|
448
|
+
const ci = run ? storage.listCiChecks(run.id) : [];
|
|
449
|
+
const reviewComments = run ? storage.listReviewComments(run.id) : [];
|
|
450
|
+
const decisions = run ? storage.listDecisions(run.id) : [];
|
|
451
|
+
const runChecks = run ? storage.listRunChecks(run.id) : [];
|
|
452
|
+
const mergeForecast = evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks });
|
|
453
|
+
return ok({
|
|
454
|
+
nextPr: selection && !selection.ambiguous ? selection.item : undefined,
|
|
455
|
+
branchName: selection && !selection.ambiguous ? selection.branchName : undefined,
|
|
456
|
+
selection: selection ? selectionSummary(selection) : genericSelectionSummary(effectiveConfig),
|
|
457
|
+
profile,
|
|
458
|
+
workflowStages: workflowStages(effectiveConfig),
|
|
459
|
+
commandsPlanned: [
|
|
460
|
+
"git status --short --branch",
|
|
461
|
+
"pnpm agent-loop run --until=gate",
|
|
462
|
+
effectiveConfig.lintCommand,
|
|
463
|
+
effectiveConfig.testCommand
|
|
464
|
+
].filter(Boolean),
|
|
465
|
+
workerType: profile.roleMapping.find((role) => role.state === (shape.id === "generic-loop" ? "EXECUTE_STEP" : "IMPLEMENT"))?.workerType ?? "implementation",
|
|
466
|
+
possibleGates: openGates.map((gate) => gate.kind),
|
|
467
|
+
missingConditions: shape.id === "pr-loop" ? mergeForecast.missingConditions : openGates.map((gate) => gate.kind),
|
|
468
|
+
filesLikelyTouched: shape.id === "pr-loop" ? likelyTouchedFiles(this.options.repoRoot, effectiveConfig.plansDir, selection && !selection.ambiguous ? selection.item.file : undefined) : genericLikelyTouchedFiles(effectiveConfig),
|
|
469
|
+
autonomyForecast: describeAutonomyPosture(effectiveConfig),
|
|
470
|
+
mergeForecast: shape.id === "pr-loop" ? mergeForecast : undefined
|
|
471
|
+
});
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
loopNotifications(): McpResult {
|
|
476
|
+
return this.withConfig((config) => this.withStorageReadOnly((storage) => {
|
|
477
|
+
const notifications = storage.readTransaction(() => {
|
|
478
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
479
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
480
|
+
const current = storage.getCurrentStatus();
|
|
481
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
482
|
+
const gates = run ? storage.listGates(run.id) : storage.listGates();
|
|
483
|
+
const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
|
|
484
|
+
const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
|
|
485
|
+
const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
|
|
486
|
+
const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
|
|
487
|
+
const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
|
|
488
|
+
const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
|
|
489
|
+
const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
|
|
490
|
+
const timelineEntries = run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [];
|
|
491
|
+
return deriveNotifications({
|
|
492
|
+
config: effectiveConfig,
|
|
493
|
+
events,
|
|
494
|
+
gates,
|
|
495
|
+
timelineEntries,
|
|
496
|
+
workers,
|
|
497
|
+
...(mergeReadiness ? { mergeReadiness } : {}),
|
|
498
|
+
...(run ? { runId: run.id } : {}),
|
|
499
|
+
now: new Date(),
|
|
500
|
+
dismissedIds: notificationDismissedIds(events)
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
return ok({ notifications });
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
loopMarkNotificationsRead(body: unknown, token?: string): McpResult {
|
|
508
|
+
const auth = this.requireToken(token);
|
|
509
|
+
if (auth) return auth;
|
|
510
|
+
return this.withConfig((config) => this.withStorage((storage) => {
|
|
511
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
512
|
+
const current = storage.getCurrentStatus();
|
|
513
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
514
|
+
const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
|
|
515
|
+
const gates = run ? storage.listGates(run.id) : storage.listGates();
|
|
516
|
+
const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
|
|
517
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
518
|
+
const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
|
|
519
|
+
const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
|
|
520
|
+
const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
|
|
521
|
+
const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
|
|
522
|
+
const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
|
|
523
|
+
const notifications = deriveNotifications({
|
|
524
|
+
config: effectiveConfig,
|
|
525
|
+
events,
|
|
526
|
+
gates,
|
|
527
|
+
timelineEntries: run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [],
|
|
528
|
+
workers,
|
|
529
|
+
...(mergeReadiness ? { mergeReadiness } : {}),
|
|
530
|
+
...(run ? { runId: run.id } : {}),
|
|
531
|
+
now: new Date(),
|
|
532
|
+
dismissedIds: notificationDismissedIds(events)
|
|
533
|
+
});
|
|
534
|
+
const requestedIds = isRecord(body) && Array.isArray(body.notificationIds)
|
|
535
|
+
? body.notificationIds.filter((id): id is string => typeof id === "string")
|
|
536
|
+
: notifications.map((notification) => notification.id);
|
|
537
|
+
const notificationIds = requestedIds.filter((id) => notifications.some((notification) => notification.id === id));
|
|
538
|
+
storage.appendEvent({
|
|
539
|
+
...(current.run ? { runId: current.run.id } : {}),
|
|
540
|
+
kind: "notification_marked_read",
|
|
541
|
+
message: `Marked ${notificationIds.length} notification(s) read.`,
|
|
542
|
+
payload: { notificationIds, source: "dashboard" }
|
|
543
|
+
});
|
|
544
|
+
return ok({ markedRead: notificationIds.length, notificationIds });
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
loopDismissNotifications(body: unknown, token?: string): McpResult {
|
|
549
|
+
const auth = this.requireToken(token);
|
|
550
|
+
if (auth) return auth;
|
|
551
|
+
return this.withConfig((config) => this.withStorage((storage) => {
|
|
552
|
+
const effectiveConfig = applyProfileConfig(config);
|
|
553
|
+
const current = storage.getCurrentStatus();
|
|
554
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
555
|
+
const events = storage.listEvents(NOTIFICATION_EVENT_LIMIT);
|
|
556
|
+
const gates = run ? storage.listGates(run.id) : storage.listGates();
|
|
557
|
+
const workers = run ? storage.listWorkers(run.id, 20) : storage.listWorkers(undefined, 20);
|
|
558
|
+
const shape = resolveLoopShape(effectiveConfig.loopShape);
|
|
559
|
+
const ci = shape.id === "pr-loop" && run ? storage.listCiChecks(run.id) : [];
|
|
560
|
+
const reviewComments = shape.id === "pr-loop" && run ? storage.listReviewComments(run.id) : [];
|
|
561
|
+
const decisions = shape.id === "pr-loop" && run ? storage.listDecisions(run.id) : [];
|
|
562
|
+
const runChecks = shape.id === "pr-loop" && run ? storage.listRunChecks(run.id) : [];
|
|
563
|
+
const mergeReadiness = shape.id === "pr-loop" ? evaluateMergeReadiness({ config: effectiveConfig, ci, reviewComments, gates, decisions, runChecks }) : undefined;
|
|
564
|
+
const notifications = deriveNotifications({
|
|
565
|
+
config: effectiveConfig,
|
|
566
|
+
events,
|
|
567
|
+
gates,
|
|
568
|
+
timelineEntries: run ? storage.listAgentTimeline({ runId: run.id, limit: 50 }).entries : [],
|
|
569
|
+
workers,
|
|
570
|
+
...(mergeReadiness ? { mergeReadiness } : {}),
|
|
571
|
+
...(run ? { runId: run.id } : {}),
|
|
572
|
+
now: new Date(),
|
|
573
|
+
dismissedIds: notificationDismissedIds(events)
|
|
574
|
+
});
|
|
575
|
+
const requestedIds = isRecord(body) && Array.isArray(body.notificationIds)
|
|
576
|
+
? body.notificationIds.filter((id): id is string => typeof id === "string")
|
|
577
|
+
: [];
|
|
578
|
+
const oneShotIds = requestedIds
|
|
579
|
+
.filter((id) => id.startsWith("longrunning:"))
|
|
580
|
+
.filter((id) => notifications.some((notification) => notification.id === id));
|
|
581
|
+
storage.appendEvent({
|
|
582
|
+
...(current.run ? { runId: current.run.id } : {}),
|
|
583
|
+
kind: "notification_dismissed",
|
|
584
|
+
message: `Dismissed ${oneShotIds.length} notification(s).`,
|
|
585
|
+
payload: { notificationIds: oneShotIds, source: "dashboard" }
|
|
586
|
+
});
|
|
587
|
+
return ok({ dismissed: oneShotIds.length, notificationIds: oneShotIds });
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
loopExportAudit(input: { runId: string; format: "markdown" | "json" }): McpResult {
|
|
592
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => storage.readTransaction(() => {
|
|
593
|
+
const run = storage.listRuns(200).find((item) => item.id === input.runId);
|
|
594
|
+
if (!run) {
|
|
595
|
+
throw new AgentLoopError("storage_error", `Run not found: ${input.runId}`);
|
|
596
|
+
}
|
|
597
|
+
const data = buildAuditData(storage, run);
|
|
598
|
+
const content = input.format === "json" ? data : renderAuditMarkdown(data);
|
|
599
|
+
return ok({ runId: run.id, format: input.format, content });
|
|
600
|
+
})));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
loopRecover(token?: string): McpResult {
|
|
604
|
+
const auth = this.requireToken(token);
|
|
605
|
+
if (auth) return auth;
|
|
606
|
+
return this.withConfig(() => ok(recoverBlockedRun(this.options.repoRoot, "dashboard")));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
loopMarkHistoricalGateHandled(gateId: string, token?: string): McpResult {
|
|
610
|
+
const auth = this.requireToken(token);
|
|
611
|
+
if (auth) return auth;
|
|
612
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
613
|
+
const gate = storage.getGate(gateId);
|
|
614
|
+
if (!gate) {
|
|
615
|
+
throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
|
|
616
|
+
}
|
|
617
|
+
const current = storage.getCurrentStatus();
|
|
618
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
619
|
+
const annotated = annotateGates({
|
|
620
|
+
gates: storage.listGates(),
|
|
621
|
+
current,
|
|
622
|
+
...(run ? { run } : {}),
|
|
623
|
+
runs: storage.listRuns(20),
|
|
624
|
+
dismissedHistoricalGateIds: historicalGateHandledIds(storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT))
|
|
625
|
+
}).find((item) => item.id === gate.id);
|
|
626
|
+
if (annotated?.activity === "active") {
|
|
627
|
+
throw new AgentLoopError("invalid_config", "Active gates must be approved, rejected, or recovered; they cannot be marked handled.");
|
|
628
|
+
}
|
|
629
|
+
const payload = {
|
|
630
|
+
gateId: gate.id,
|
|
631
|
+
gateKind: gate.kind,
|
|
632
|
+
gateRunId: gate.runId,
|
|
633
|
+
gateStatus: gate.status,
|
|
634
|
+
activity: annotated?.activity ?? "historical",
|
|
635
|
+
activityReason: annotated?.activityReason ?? "historical_run",
|
|
636
|
+
source: "dashboard"
|
|
637
|
+
};
|
|
638
|
+
storage.appendEvent({
|
|
639
|
+
...(gate.runId ? { runId: gate.runId } : {}),
|
|
640
|
+
kind: HISTORICAL_EVENT_KIND,
|
|
641
|
+
message: `Marked historical gate ${gate.id} as handled in the dashboard view.`,
|
|
642
|
+
payload
|
|
643
|
+
});
|
|
644
|
+
if (gate.runId) {
|
|
645
|
+
storage.appendDecision({
|
|
646
|
+
runId: gate.runId,
|
|
647
|
+
kind: HISTORICAL_EVENT_KIND,
|
|
648
|
+
message: `Marked historical gate ${gate.id} as handled in the dashboard view.`,
|
|
649
|
+
details: payload
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return ok({ gate: { ...gate, activity: "historical", activityReason: "marked_handled" }, markedHandled: true });
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
loopReevaluateHistoricalGate(gateId: string, token?: string): McpResult {
|
|
657
|
+
const auth = this.requireToken(token);
|
|
658
|
+
if (auth) return auth;
|
|
659
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
660
|
+
this.reconcileTerminalWorker(storage);
|
|
661
|
+
const gate = storage.getGate(gateId);
|
|
662
|
+
if (!gate) {
|
|
663
|
+
throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
|
|
664
|
+
}
|
|
665
|
+
const current = storage.getCurrentStatus();
|
|
666
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
667
|
+
const annotated = annotateGates({
|
|
668
|
+
gates: storage.listGates(),
|
|
669
|
+
current,
|
|
670
|
+
...(run ? { run } : {}),
|
|
671
|
+
runs: storage.listRuns(20),
|
|
672
|
+
dismissedHistoricalGateIds: historicalGateHandledIds(storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT))
|
|
673
|
+
}).find((item) => item.id === gate.id);
|
|
674
|
+
const result = reevaluationResult(annotated?.activity, annotated?.activityReason);
|
|
675
|
+
const payload = {
|
|
676
|
+
gateId: gate.id,
|
|
677
|
+
gateKind: gate.kind,
|
|
678
|
+
gateRunId: gate.runId,
|
|
679
|
+
gateStatus: gate.status,
|
|
680
|
+
activity: annotated?.activity ?? "historical",
|
|
681
|
+
activityReason: annotated?.activityReason ?? "historical_run",
|
|
682
|
+
result,
|
|
683
|
+
source: "dashboard"
|
|
684
|
+
};
|
|
685
|
+
storage.appendEvent({
|
|
686
|
+
...(gate.runId ? { runId: gate.runId } : {}),
|
|
687
|
+
kind: HISTORICAL_REEVALUATED_EVENT_KIND,
|
|
688
|
+
message: `Re-evaluated historical gate ${gate.id} in the dashboard view.`,
|
|
689
|
+
payload
|
|
690
|
+
});
|
|
691
|
+
if (gate.runId) {
|
|
692
|
+
storage.appendDecision({
|
|
693
|
+
runId: gate.runId,
|
|
694
|
+
kind: HISTORICAL_REEVALUATED_EVENT_KIND,
|
|
695
|
+
message: `Re-evaluated historical gate ${gate.id} in the dashboard view.`,
|
|
696
|
+
details: payload
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return ok({ gate: { ...gate, activity: payload.activity, activityReason: payload.activityReason }, result, reevaluated: true });
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async loopSpawnWorker(type: WorkerType, dryRun = true, token?: string): Promise<McpResult> {
|
|
704
|
+
const auth = this.requireToken(token);
|
|
705
|
+
if (auth) return auth;
|
|
706
|
+
return await this.withConfigAsync(async (config) => {
|
|
707
|
+
const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot));
|
|
708
|
+
try {
|
|
709
|
+
const run = storage.getCurrentRun();
|
|
710
|
+
if (!run) {
|
|
711
|
+
throw new AgentLoopError("storage_error", "No current run exists.");
|
|
712
|
+
}
|
|
713
|
+
storage.appendDecision({
|
|
714
|
+
runId: run.id,
|
|
715
|
+
kind: "mcp_spawn_worker_requested",
|
|
716
|
+
message: `MCP requested ${type} worker.`,
|
|
717
|
+
details: { type, dryRun }
|
|
718
|
+
});
|
|
719
|
+
return ok(await executeWorker({
|
|
720
|
+
repoRoot: this.options.repoRoot,
|
|
721
|
+
storage,
|
|
722
|
+
run,
|
|
723
|
+
config,
|
|
724
|
+
state: workerState(run.currentState),
|
|
725
|
+
type,
|
|
726
|
+
dryRun
|
|
727
|
+
}));
|
|
728
|
+
} catch (error) {
|
|
729
|
+
return fail(error);
|
|
730
|
+
} finally {
|
|
731
|
+
storage.close();
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
loopOpenDashboard(): McpResult {
|
|
737
|
+
return this.withConfig((config) => {
|
|
738
|
+
if (!config.dashboard?.enabled) {
|
|
739
|
+
return ok({ enabled: false, message: "Run `pnpm agent-loop dashboard` to start the local dashboard." });
|
|
740
|
+
}
|
|
741
|
+
const port = config.dashboard.port ?? 0;
|
|
742
|
+
return ok({ enabled: true, url: `http://${config.dashboard.host}:${port}` });
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private decideGate(gateId: string, decision: "approved" | "rejected", input: string | GateDecisionInput, token?: string): McpResult {
|
|
747
|
+
const auth = this.requireToken(token);
|
|
748
|
+
if (auth) return auth;
|
|
749
|
+
const decisionInput = normalizeGateDecisionInput(input);
|
|
750
|
+
const note = decisionInput.note;
|
|
751
|
+
if (note.trim().length === 0) {
|
|
752
|
+
return fail(new AgentLoopError("invalid_config", "Gate approval note is required."));
|
|
753
|
+
}
|
|
754
|
+
return this.withConfig(() => this.withStorage((storage) => {
|
|
755
|
+
const gate = storage.decideGate(gateId, decision, note);
|
|
756
|
+
const runId = gate.runId ?? storage.getCurrentRun()?.id;
|
|
757
|
+
if (runId) {
|
|
758
|
+
storage.appendDecision({
|
|
759
|
+
runId,
|
|
760
|
+
kind: `gate_${decision}`,
|
|
761
|
+
message: `${decision} gate ${gate.id}.`,
|
|
762
|
+
details: {
|
|
763
|
+
gateId: gate.id,
|
|
764
|
+
gateKind: gate.kind,
|
|
765
|
+
state: gateState(gate.details),
|
|
766
|
+
note,
|
|
767
|
+
source: decisionInput.source ?? "api",
|
|
768
|
+
payload: decisionInput.payload ?? {},
|
|
769
|
+
gateDetails: gate.details
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
return ok({ gate });
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private withConfig(fn: (config: ReturnType<typeof loadConfig>["config"]) => McpResult): McpResult {
|
|
778
|
+
try {
|
|
779
|
+
const { config } = loadConfig(this.options.repoRoot);
|
|
780
|
+
return fn(config);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
return fail(error);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private async withConfigAsync(fn: (config: ReturnType<typeof loadConfig>["config"]) => Promise<McpResult>): Promise<McpResult> {
|
|
787
|
+
try {
|
|
788
|
+
const { config } = loadConfig(this.options.repoRoot);
|
|
789
|
+
return await fn(config);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
return fail(error);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private withStorage(fn: (storage: SqliteAgentLoopStorage) => McpResult): McpResult {
|
|
796
|
+
const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot));
|
|
797
|
+
try {
|
|
798
|
+
return fn(storage);
|
|
799
|
+
} catch (error) {
|
|
800
|
+
return fail(error);
|
|
801
|
+
} finally {
|
|
802
|
+
storage.close();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private withStorageReadOnly(fn: (storage: SqliteAgentLoopStorage) => McpResult): McpResult {
|
|
807
|
+
const storage = new SqliteAgentLoopStorage(statePath(this.options.repoRoot), { mode: "ro" });
|
|
808
|
+
try {
|
|
809
|
+
return fn(storage);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
return fail(error);
|
|
812
|
+
} finally {
|
|
813
|
+
storage.close();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private withCurrentRun(fn: (storage: SqliteAgentLoopStorage, run: AgentLoopRun) => McpResult): McpResult {
|
|
818
|
+
return this.withConfig(() => this.withStorageReadOnly((storage) => {
|
|
819
|
+
const run = storage.getCurrentRun();
|
|
820
|
+
if (!run) {
|
|
821
|
+
throw new AgentLoopError("storage_error", "No current run exists.");
|
|
822
|
+
}
|
|
823
|
+
return fn(storage, run);
|
|
824
|
+
}));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private requireToken(token: string | undefined): McpResult | undefined {
|
|
828
|
+
return requireMcpToken(token, this.options.mcpToken);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private reconcileTerminalWorker(storage: SqliteAgentLoopStorage): void {
|
|
832
|
+
const run = storage.getCurrentRun();
|
|
833
|
+
if (run) {
|
|
834
|
+
blockRunForTerminalWorker(storage, run);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function isSensitiveArtifactKind(kind: string): boolean {
|
|
840
|
+
return ["worker-prompt", "worker-jsonl", "worker-result"].includes(kind);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function normalizeGateDecisionInput(input: string | GateDecisionInput): GateDecisionInput {
|
|
844
|
+
if (typeof input === "string") {
|
|
845
|
+
return { note: input };
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
note: input.note,
|
|
849
|
+
...(input.source ? { source: input.source } : {}),
|
|
850
|
+
...(input.payload ? { payload: input.payload } : {})
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function gateState(details: unknown): string | undefined {
|
|
855
|
+
if (!isRecord(details)) return undefined;
|
|
856
|
+
return typeof details.state === "string" ? details.state : undefined;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function selectionSummary(selection: PrSelection): Record<string, unknown> {
|
|
860
|
+
if (selection.mode === "ambiguous") {
|
|
861
|
+
return {
|
|
862
|
+
mode: selection.mode,
|
|
863
|
+
ambiguous: true,
|
|
864
|
+
reason: selection.reason,
|
|
865
|
+
candidates: selection.candidates,
|
|
866
|
+
evidence: selection.evidence
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
mode: selection.mode,
|
|
871
|
+
ambiguous: false,
|
|
872
|
+
item: selection.item,
|
|
873
|
+
branchName: selection.branchName,
|
|
874
|
+
...(selection.mode === "current_pr" ? {
|
|
875
|
+
prNumber: selection.pr.number,
|
|
876
|
+
prUrl: selection.pr.url
|
|
877
|
+
} : {}),
|
|
878
|
+
evidence: selection.evidence
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function genericSelectionSummary(config: AgentLoopConfig): Record<string, unknown> {
|
|
883
|
+
return {
|
|
884
|
+
mode: "generic_loop",
|
|
885
|
+
ambiguous: false,
|
|
886
|
+
loopShape: config.loopShape,
|
|
887
|
+
workflowProfile: config.workflowProfile,
|
|
888
|
+
evidence: ["generic-loop uses workflow profile state, not legacy PR spec selection."]
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function genericLikelyTouchedFiles(config: AgentLoopConfig): string[] {
|
|
893
|
+
const profile = resolveProfile(config);
|
|
894
|
+
const allowedRoots = profile.allowedWriteRoots ?? [];
|
|
895
|
+
return [
|
|
896
|
+
...allowedRoots.map((root) => `${root}/`),
|
|
897
|
+
".agent-loop/artifacts/"
|
|
898
|
+
];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function dashboardInfo(config: AgentLoopConfig): { url: string; host: string; port: number; loopbackOnly: true } {
|
|
902
|
+
const host = config.dashboard?.host ?? "127.0.0.1";
|
|
903
|
+
const port = config.dashboard?.port ?? 0;
|
|
904
|
+
return { url: `http://${host}:${port}/`, host, port, loopbackOnly: true };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function notificationReadIds(events: AgentLoopEvent[]): Set<string> {
|
|
908
|
+
return notificationIdsForKind(events, "notification_marked_read");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function notificationDismissedIds(events: AgentLoopEvent[]): Set<string> {
|
|
912
|
+
return notificationIdsForKind(events, "notification_dismissed");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function notificationIdsForKind(events: AgentLoopEvent[], kind: string): Set<string> {
|
|
916
|
+
const ids = new Set<string>();
|
|
917
|
+
for (const event of events) {
|
|
918
|
+
if (event.kind !== kind || typeof event.payload !== "object" || event.payload === null) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const notificationIds = (event.payload as { notificationIds?: unknown }).notificationIds;
|
|
922
|
+
if (Array.isArray(notificationIds)) {
|
|
923
|
+
for (const id of notificationIds) {
|
|
924
|
+
if (typeof id === "string") ids.add(id);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return ids;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function mergeReadinessForMissionDisplay(readiness: MergeReadiness, events: AgentLoopEvent[]): MergeReadiness {
|
|
932
|
+
if (!hasWorkflowCleanupEvidence(events)) {
|
|
933
|
+
return readiness;
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
...readiness,
|
|
937
|
+
state: "ready",
|
|
938
|
+
ready: true,
|
|
939
|
+
missingConditions: [],
|
|
940
|
+
evidence: [...readiness.evidence, "cleanup evidence recorded after merge"]
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function hasWorkflowCleanupEvidence(events: AgentLoopEvent[]): boolean {
|
|
945
|
+
return events.some((event) => {
|
|
946
|
+
if (event.kind !== WORKFLOW_STAGE_EVIDENCE_KIND || !isRecord(event.payload)) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
return event.payload.stageId === "cleanup";
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function buildAuditData(storage: SqliteAgentLoopStorage, run: AgentLoopRun): Record<string, unknown> {
|
|
954
|
+
const gates = storage.listGates(run.id).map((gate) => ({
|
|
955
|
+
id: gate.id,
|
|
956
|
+
runId: gate.runId,
|
|
957
|
+
kind: gate.kind,
|
|
958
|
+
status: gate.status,
|
|
959
|
+
message: redactSecrets(gate.message),
|
|
960
|
+
details: redactAuditValue(gate.details),
|
|
961
|
+
createdAt: gate.createdAt,
|
|
962
|
+
resolvedAt: gate.resolvedAt,
|
|
963
|
+
decisionNote: gate.decisionNote ? redactSecrets(gate.decisionNote) : undefined,
|
|
964
|
+
decidedAt: gate.decidedAt
|
|
965
|
+
}));
|
|
966
|
+
const ci = storage.listCiChecks(run.id);
|
|
967
|
+
const reviewComments = storage.listReviewComments(run.id).map((comment) => ({
|
|
968
|
+
id: comment.id,
|
|
969
|
+
prNumber: comment.prNumber,
|
|
970
|
+
url: comment.url,
|
|
971
|
+
author: comment.author,
|
|
972
|
+
path: comment.path,
|
|
973
|
+
line: comment.line,
|
|
974
|
+
actionable: comment.actionable,
|
|
975
|
+
isResolved: comment.isResolved,
|
|
976
|
+
isOutdated: comment.isOutdated,
|
|
977
|
+
status: comment.status
|
|
978
|
+
}));
|
|
979
|
+
const timeline = storage.listAgentTimeline({ runId: run.id, limit: 200 }).entries.map((entry) => ({
|
|
980
|
+
timelineSeq: entry.timelineSeq,
|
|
981
|
+
occurredAt: entry.occurredAt,
|
|
982
|
+
source: entry.source,
|
|
983
|
+
kind: entry.kind,
|
|
984
|
+
workerId: entry.workerId,
|
|
985
|
+
threadId: entry.threadId,
|
|
986
|
+
title: redactSecrets(entry.title),
|
|
987
|
+
summary: redactSecrets(entry.summary).slice(0, 2_000),
|
|
988
|
+
status: entry.status,
|
|
989
|
+
artifactIds: entry.artifactIds
|
|
990
|
+
}));
|
|
991
|
+
return {
|
|
992
|
+
generatedAt: new Date().toISOString(),
|
|
993
|
+
run,
|
|
994
|
+
pr: storage.getPrLink(run.id),
|
|
995
|
+
ci,
|
|
996
|
+
reviewComments,
|
|
997
|
+
workers: storage.listWorkers(run.id, 100).map((worker) => ({
|
|
998
|
+
...worker,
|
|
999
|
+
...(worker.error ? { error: redactSecrets(worker.error) } : {})
|
|
1000
|
+
})),
|
|
1001
|
+
gates,
|
|
1002
|
+
decisions: storage.listDecisions(run.id).map((decision) => ({
|
|
1003
|
+
...decision,
|
|
1004
|
+
message: redactSecrets(decision.message),
|
|
1005
|
+
details: redactAuditValue(decision.details)
|
|
1006
|
+
})),
|
|
1007
|
+
artifacts: storage.listArtifacts(run.id).map((artifact) => ({
|
|
1008
|
+
id: artifact.id,
|
|
1009
|
+
runId: artifact.runId,
|
|
1010
|
+
kind: artifact.kind,
|
|
1011
|
+
name: redactSecrets(artifact.name),
|
|
1012
|
+
path: redactSecrets(artifact.path),
|
|
1013
|
+
sha256: artifact.sha256,
|
|
1014
|
+
createdAt: artifact.createdAt
|
|
1015
|
+
})),
|
|
1016
|
+
timeline
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function renderAuditMarkdown(data: Record<string, unknown>): string {
|
|
1021
|
+
const run = data.run as AgentLoopRun;
|
|
1022
|
+
const pr = data.pr as { prNumber?: number; url?: string; state?: string } | undefined;
|
|
1023
|
+
const workers = data.workers as WorkerRun[];
|
|
1024
|
+
const gates = data.gates as Array<{ kind: string; status: string; message: string }>;
|
|
1025
|
+
const timeline = data.timeline as Array<{ occurredAt: string; source: string; title: string; status?: string }>;
|
|
1026
|
+
const lines = [
|
|
1027
|
+
`# Agent Loop Audit: ${run.id}`,
|
|
1028
|
+
"",
|
|
1029
|
+
`- Status: ${run.status}`,
|
|
1030
|
+
`- State: ${run.currentState ?? "unknown"}`,
|
|
1031
|
+
`- Branch: ${run.branch ?? "unknown"}`,
|
|
1032
|
+
`- Generated: ${data.generatedAt}`,
|
|
1033
|
+
pr ? `- PR: #${pr.prNumber ?? "unknown"} ${pr.state ?? ""} ${pr.url ?? ""}` : "- PR: none",
|
|
1034
|
+
"",
|
|
1035
|
+
"## Gates",
|
|
1036
|
+
...listLines(gates.map((gate) => `${gate.kind} / ${gate.status} - ${redactSecrets(gate.message)}`)),
|
|
1037
|
+
"",
|
|
1038
|
+
"## Workers",
|
|
1039
|
+
...listLines(workers.map((worker) => `${worker.id} / ${worker.type} / ${worker.status}`)),
|
|
1040
|
+
"",
|
|
1041
|
+
"## Timeline",
|
|
1042
|
+
...listLines(timeline.slice(0, 80).map((entry) => `${entry.occurredAt} ${entry.source} ${entry.status ?? ""} - ${entry.title}`))
|
|
1043
|
+
];
|
|
1044
|
+
return `${lines.join("\n")}\n`;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function listLines(items: string[]): string[] {
|
|
1048
|
+
return items.length === 0 ? ["- none"] : items.map((item) => `- ${item}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function redactAuditValue(value: unknown): unknown {
|
|
1052
|
+
if (typeof value === "string") {
|
|
1053
|
+
return redactSecrets(value).slice(0, 2_000);
|
|
1054
|
+
}
|
|
1055
|
+
if (Array.isArray(value)) {
|
|
1056
|
+
return value.slice(0, 20).map(redactAuditValue);
|
|
1057
|
+
}
|
|
1058
|
+
if (typeof value !== "object" || value === null) {
|
|
1059
|
+
return value;
|
|
1060
|
+
}
|
|
1061
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>).slice(0, 40).map(([key, nested]) => [
|
|
1062
|
+
key,
|
|
1063
|
+
redactAuditField(key, nested)
|
|
1064
|
+
]));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function redactAuditField(key: string, value: unknown): unknown {
|
|
1068
|
+
if (/token|api_key|authorization|password|secret/i.test(key)) {
|
|
1069
|
+
return "[redacted]";
|
|
1070
|
+
}
|
|
1071
|
+
if (/stdout|stderr|output|rawJsonl|contentBase64|prompt/i.test(key)) {
|
|
1072
|
+
return {
|
|
1073
|
+
omitted: true,
|
|
1074
|
+
reason: "raw content is excluded from audit exports",
|
|
1075
|
+
length: typeof value === "string" ? value.length : JSON.stringify(value ?? "").length,
|
|
1076
|
+
type: Array.isArray(value) ? "array" : typeof value
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return redactAuditValue(value);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function buildTimelineSummary(input: {
|
|
1083
|
+
timeline: AgentTimelineEntry[];
|
|
1084
|
+
workers: WorkerRun[];
|
|
1085
|
+
currentRunId?: string;
|
|
1086
|
+
nowMs?: number;
|
|
1087
|
+
listWorkerEvents: (workerId: string) => Array<{ eventType: string; itemType?: string; createdAt: string }>;
|
|
1088
|
+
}): Record<string, unknown> {
|
|
1089
|
+
const activeWorker = input.workers.find((worker) => worker.status === "running");
|
|
1090
|
+
const lastFailure = input.timeline.find((entry) =>
|
|
1091
|
+
entry.source === "worker" &&
|
|
1092
|
+
(entry.status === "failed" || entry.status === "timed_out" || entry.status === "invalid_output")
|
|
1093
|
+
);
|
|
1094
|
+
const summary: Record<string, unknown> = {
|
|
1095
|
+
hasObservationGap: hasObservationGap(input.workers, input.listWorkerEvents, input.nowMs),
|
|
1096
|
+
...(input.currentRunId ? { runId: input.currentRunId } : {})
|
|
1097
|
+
};
|
|
1098
|
+
if (input.timeline[0]) {
|
|
1099
|
+
summary.latest = input.timeline[0];
|
|
1100
|
+
}
|
|
1101
|
+
if (lastFailure) {
|
|
1102
|
+
summary.lastFailure = lastFailure;
|
|
1103
|
+
}
|
|
1104
|
+
if (activeWorker) {
|
|
1105
|
+
summary.activeWorker = {
|
|
1106
|
+
id: activeWorker.id,
|
|
1107
|
+
type: activeWorker.type,
|
|
1108
|
+
status: activeWorker.status,
|
|
1109
|
+
...(activeWorker.threadId ? { threadId: activeWorker.threadId } : {}),
|
|
1110
|
+
startedAt: activeWorker.startedAt
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return summary;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function hasObservationGap(
|
|
1117
|
+
workers: WorkerRun[],
|
|
1118
|
+
listWorkerEvents: (workerId: string) => Array<{ eventType: string; itemType?: string; createdAt: string }>,
|
|
1119
|
+
nowMs = Date.now()
|
|
1120
|
+
): boolean {
|
|
1121
|
+
return workers.some((worker) => {
|
|
1122
|
+
const events = listWorkerEvents(worker.id);
|
|
1123
|
+
if (events.length === 0 && !worker.rawJsonlArtifactId) {
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
const hasSummary = events.some((event) =>
|
|
1127
|
+
event.eventType === "thread.started" ||
|
|
1128
|
+
event.eventType === "turn.completed" ||
|
|
1129
|
+
event.itemType === "command_execution" ||
|
|
1130
|
+
event.itemType === "file_change" ||
|
|
1131
|
+
event.itemType === "agent_message" ||
|
|
1132
|
+
event.itemType === "mcp_tool_call" ||
|
|
1133
|
+
event.itemType === "web_search" ||
|
|
1134
|
+
event.itemType === "todo_list" ||
|
|
1135
|
+
event.itemType === "error"
|
|
1136
|
+
);
|
|
1137
|
+
if (worker.rawJsonlArtifactId && !hasSummary) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
if (worker.status !== "running") {
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
const startedAt = Date.parse(worker.startedAt);
|
|
1144
|
+
if (Number.isNaN(startedAt) || nowMs - startedAt <= 60_000) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
const newestEventMs = events.reduce((latest, event) => {
|
|
1148
|
+
const value = Date.parse(event.createdAt);
|
|
1149
|
+
return Number.isNaN(value) ? latest : Math.max(latest, value);
|
|
1150
|
+
}, 0);
|
|
1151
|
+
return newestEventMs < startedAt || nowMs - newestEventMs > 60_000;
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function assertArtifactPathInsideRoot(artifactRoot: string, path: string, artifactId: string): void {
|
|
1156
|
+
try {
|
|
1157
|
+
const rootRealPath = realpathSync(artifactRoot);
|
|
1158
|
+
const artifactRealPath = realpathSync(path);
|
|
1159
|
+
const relativePath = relative(rootRealPath, artifactRealPath);
|
|
1160
|
+
if (relativePath.startsWith("..") || relativePath === "" || relativePath.startsWith("/")) {
|
|
1161
|
+
throw new AgentLoopError("artifact_integrity_error", "Artifact path escapes artifact root.", {
|
|
1162
|
+
details: { artifactId, path }
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error instanceof AgentLoopError) {
|
|
1167
|
+
throw error;
|
|
1168
|
+
}
|
|
1169
|
+
throw new AgentLoopError("artifact_integrity_error", "Artifact path cannot be verified.", {
|
|
1170
|
+
details: { artifactId, path, cause: error instanceof Error ? error.message : String(error) }
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function startBackgroundRun(repoRoot: string, runId: string): boolean {
|
|
1176
|
+
try {
|
|
1177
|
+
execFileSync("which", ["pnpm"], { stdio: "ignore" });
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
markBackgroundRunFailed(repoRoot, runId, error);
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
const child = spawn("pnpm", ["agent-loop", "run", "--until=gate"], {
|
|
1183
|
+
cwd: repoRoot,
|
|
1184
|
+
detached: true,
|
|
1185
|
+
stdio: "ignore",
|
|
1186
|
+
shell: false
|
|
1187
|
+
});
|
|
1188
|
+
let handledStartFailure = false;
|
|
1189
|
+
const failStartOnce = (error: unknown): void => {
|
|
1190
|
+
if (handledStartFailure) {
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
handledStartFailure = true;
|
|
1194
|
+
markBackgroundRunFailed(repoRoot, runId, error);
|
|
1195
|
+
};
|
|
1196
|
+
child.on("error", (error) => {
|
|
1197
|
+
failStartOnce(error);
|
|
1198
|
+
});
|
|
1199
|
+
if (!child.pid) {
|
|
1200
|
+
failStartOnce(new Error("Background process did not start."));
|
|
1201
|
+
return false;
|
|
1202
|
+
}
|
|
1203
|
+
child.unref();
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function markBackgroundRunFailed(repoRoot: string, runId: string, error: unknown): void {
|
|
1208
|
+
let storage: SqliteAgentLoopStorage | undefined;
|
|
1209
|
+
try {
|
|
1210
|
+
storage = new SqliteAgentLoopStorage(statePath(repoRoot));
|
|
1211
|
+
const run = storage.getCurrentRun();
|
|
1212
|
+
if (run?.id === runId && run.status === "RUNNING") {
|
|
1213
|
+
try {
|
|
1214
|
+
storage.updateRunStatus(run.id, run.version, "BLOCKED", {
|
|
1215
|
+
...(run.currentState ? { currentState: run.currentState } : {})
|
|
1216
|
+
});
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Best-effort cleanup; the run may have advanced concurrently.
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
storage.writeGate({
|
|
1223
|
+
runId,
|
|
1224
|
+
kind: "required_tool_unavailable",
|
|
1225
|
+
message: "Could not start background agent-loop run.",
|
|
1226
|
+
details: { cause: error instanceof Error ? error.message : String(error) }
|
|
1227
|
+
});
|
|
1228
|
+
storage.appendEvent({
|
|
1229
|
+
runId,
|
|
1230
|
+
kind: "background_run_start_failed",
|
|
1231
|
+
message: "Could not start background agent-loop run.",
|
|
1232
|
+
payload: { cause: error instanceof Error ? error.message : String(error) }
|
|
1233
|
+
});
|
|
1234
|
+
} catch {
|
|
1235
|
+
// Async process-start failure handling must never crash the MCP server.
|
|
1236
|
+
}
|
|
1237
|
+
} catch {
|
|
1238
|
+
// Best-effort cleanup path.
|
|
1239
|
+
} finally {
|
|
1240
|
+
storage?.close();
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function nextAction(status: string, gate?: string): string {
|
|
1245
|
+
if (gate === "needs_repo_init") {
|
|
1246
|
+
return "Run `pnpm agent-loop init`.";
|
|
1247
|
+
}
|
|
1248
|
+
if (gate) {
|
|
1249
|
+
return "Inspect the gate, fix the cause, then approve or reject with a note.";
|
|
1250
|
+
}
|
|
1251
|
+
if (status === "IDLE" || status === "READY") {
|
|
1252
|
+
return "Run until the next gate.";
|
|
1253
|
+
}
|
|
1254
|
+
if (status === "STOPPED") {
|
|
1255
|
+
return "Resume only after confirming the stopped run should continue.";
|
|
1256
|
+
}
|
|
1257
|
+
return "Poll status.";
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function ok<T>(data: T): McpResult<T> {
|
|
1261
|
+
return { ok: true, data };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function fail(error: unknown): McpResult {
|
|
1265
|
+
const payload = toErrorPayload(error);
|
|
1266
|
+
return {
|
|
1267
|
+
ok: false,
|
|
1268
|
+
error: payload,
|
|
1269
|
+
...(payload.code ? { gate: payload.code } : {})
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function requireMcpToken(token: string | undefined, expectedToken?: string): McpResult | undefined {
|
|
1274
|
+
const expected = expectedToken ?? process.env.AGENT_LOOP_MCP_TOKEN;
|
|
1275
|
+
if (!expected) {
|
|
1276
|
+
return fail(new AgentLoopError("needs_secret_or_login", "AGENT_LOOP_MCP_TOKEN is required for mutating MCP tools.", {
|
|
1277
|
+
exitCode: 2
|
|
1278
|
+
}));
|
|
1279
|
+
}
|
|
1280
|
+
if (token !== expected) {
|
|
1281
|
+
return fail(new AgentLoopError("needs_secret_or_login", "MCP token is missing or invalid.", {
|
|
1282
|
+
exitCode: 2
|
|
1283
|
+
}));
|
|
1284
|
+
}
|
|
1285
|
+
return undefined;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function recoveryWarnings(
|
|
1289
|
+
gate: string | undefined,
|
|
1290
|
+
gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }> = [],
|
|
1291
|
+
workers: Array<WorkerRun & { activity?: "active" | "historical"; activityReason?: string }> = []
|
|
1292
|
+
): string[] {
|
|
1293
|
+
const warnings = gate === "needs_repo_init"
|
|
1294
|
+
? ["needs_repo_init is visible; use explicit recovery after config is valid."]
|
|
1295
|
+
: [];
|
|
1296
|
+
const historicalOpen = gates.filter((item) => item.status === "open" && item.activity === "historical");
|
|
1297
|
+
if (historicalOpen.length > 0) {
|
|
1298
|
+
warnings.push(`${historicalOpen.length} historical open gate(s) belong to an inactive or superseded run.`);
|
|
1299
|
+
}
|
|
1300
|
+
const staleWorkers = workers.filter((item) => item.activityReason === "stale_worker_failure");
|
|
1301
|
+
if (staleWorkers.length > 0) {
|
|
1302
|
+
warnings.push(`${staleWorkers.length} stale worker failure(s) are from an older run or before the current run started.`);
|
|
1303
|
+
}
|
|
1304
|
+
return warnings;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function currentForMissionControl(
|
|
1308
|
+
current: ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]>,
|
|
1309
|
+
gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }>
|
|
1310
|
+
): ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]> {
|
|
1311
|
+
const activeGate = gates.find((gate) => gate.status === "open" && gate.activity === "active");
|
|
1312
|
+
if (activeGate) {
|
|
1313
|
+
return {
|
|
1314
|
+
...current,
|
|
1315
|
+
status: "BLOCKED",
|
|
1316
|
+
gate: {
|
|
1317
|
+
kind: activeGate.kind,
|
|
1318
|
+
message: activeGate.message,
|
|
1319
|
+
...(activeGate.details === undefined ? {} : { details: activeGate.details })
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
if (!current.gate) {
|
|
1324
|
+
return current;
|
|
1325
|
+
}
|
|
1326
|
+
const { gate: _gate, ...withoutGate } = current;
|
|
1327
|
+
return {
|
|
1328
|
+
...withoutGate,
|
|
1329
|
+
status: current.run?.status ?? current.status
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function annotatedGatesSnapshot(storage: SqliteAgentLoopStorage): Array<AgentLoopGate & { activity: "active" | "historical"; activityReason: string }> {
|
|
1334
|
+
return storage.readTransaction(() => {
|
|
1335
|
+
const current = storage.getCurrentStatus();
|
|
1336
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
1337
|
+
const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
|
|
1338
|
+
return annotateGates({
|
|
1339
|
+
gates: storage.listGates(),
|
|
1340
|
+
current,
|
|
1341
|
+
...(run ? { run } : {}),
|
|
1342
|
+
runs: storage.listRuns(20),
|
|
1343
|
+
dismissedHistoricalGateIds: historicalGateHandledIds(historicalEvents)
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function annotatedGateSnapshot(storage: SqliteAgentLoopStorage, gateId: string): (AgentLoopGate & { activity: "active" | "historical"; activityReason: string }) | undefined {
|
|
1349
|
+
return storage.readTransaction(() => {
|
|
1350
|
+
const gate = storage.getGate(gateId);
|
|
1351
|
+
if (!gate) return undefined;
|
|
1352
|
+
const current = storage.getCurrentStatus();
|
|
1353
|
+
const run = current.run ?? storage.getCurrentRun();
|
|
1354
|
+
const historicalEvents = storage.listEvents(HISTORICAL_EVENT_SCAN_LIMIT);
|
|
1355
|
+
return annotateGates({
|
|
1356
|
+
gates: [gate],
|
|
1357
|
+
current,
|
|
1358
|
+
...(run ? { run } : {}),
|
|
1359
|
+
runs: includeRun(storage.listRuns(20), run),
|
|
1360
|
+
dismissedHistoricalGateIds: historicalGateHandledIds(historicalEvents)
|
|
1361
|
+
})[0];
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function includeRun(runs: AgentLoopRun[], run: AgentLoopRun | undefined): AgentLoopRun[] {
|
|
1366
|
+
if (!run || runs.some((item) => item.id === run.id)) return runs;
|
|
1367
|
+
return [run, ...runs];
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function annotateGates(input: {
|
|
1371
|
+
gates: AgentLoopGate[];
|
|
1372
|
+
current: ReturnType<SqliteAgentLoopStorage["getCurrentStatus"]>;
|
|
1373
|
+
run?: AgentLoopRun;
|
|
1374
|
+
runs: AgentLoopRun[];
|
|
1375
|
+
dismissedHistoricalGateIds: Set<string>;
|
|
1376
|
+
}): Array<AgentLoopGate & { activity: "active" | "historical"; activityReason: string }> {
|
|
1377
|
+
const runById = new Map(input.runs.map((run) => [run.id, run]));
|
|
1378
|
+
const currentRunId = input.run?.id;
|
|
1379
|
+
const currentStartedAt = input.run?.startedAt ? Date.parse(input.run.startedAt) : (input.run?.createdAt ? Date.parse(input.run.createdAt) : undefined);
|
|
1380
|
+
return input.gates.map((gate) => {
|
|
1381
|
+
const gateRun = gate.runId ? runById.get(gate.runId) : undefined;
|
|
1382
|
+
const activeRun = gateRun ? isActiveRun(gateRun) && gateRun.id === currentRunId : input.current.gate?.kind === gate.kind;
|
|
1383
|
+
const inactiveGateRun = gateRun ? !isActiveRun(gateRun) : false;
|
|
1384
|
+
const gateCreatedAt = Date.parse(gate.createdAt);
|
|
1385
|
+
const supersededByCurrentRun = gate.runId !== undefined &&
|
|
1386
|
+
gate.runId !== currentRunId &&
|
|
1387
|
+
currentRunId !== undefined &&
|
|
1388
|
+
(inactiveGateRun || (
|
|
1389
|
+
currentStartedAt !== undefined &&
|
|
1390
|
+
!Number.isNaN(gateCreatedAt) &&
|
|
1391
|
+
gateCreatedAt < currentStartedAt
|
|
1392
|
+
));
|
|
1393
|
+
if (gate.status === "open" && activeRun && !supersededByCurrentRun) {
|
|
1394
|
+
return { ...gate, activity: "active" as const, activityReason: gate.runId ? "current_run" : "repo_gate" };
|
|
1395
|
+
}
|
|
1396
|
+
if (input.dismissedHistoricalGateIds.has(gate.id)) {
|
|
1397
|
+
return { ...gate, activity: "historical" as const, activityReason: "marked_handled" };
|
|
1398
|
+
}
|
|
1399
|
+
if (supersededByCurrentRun) {
|
|
1400
|
+
return { ...gate, activity: "historical" as const, activityReason: "overridden_by_reality" };
|
|
1401
|
+
}
|
|
1402
|
+
if (gate.status !== "open") {
|
|
1403
|
+
return { ...gate, activity: "historical" as const, activityReason: "handled_gate" };
|
|
1404
|
+
}
|
|
1405
|
+
return { ...gate, activity: "historical" as const, activityReason: gateRun ? "historical_run" : "repo_gate_not_current" };
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function annotateWorkers(input: {
|
|
1410
|
+
workers: WorkerRun[];
|
|
1411
|
+
gates: Array<AgentLoopGate & { activity?: "active" | "historical"; activityReason?: string }>;
|
|
1412
|
+
run?: AgentLoopRun;
|
|
1413
|
+
}): Array<WorkerRun & { activity: "active" | "historical"; activityReason: string }> {
|
|
1414
|
+
const currentRunId = input.run?.id;
|
|
1415
|
+
const currentStartedAt = input.run?.startedAt ? Date.parse(input.run.startedAt) : undefined;
|
|
1416
|
+
return input.workers.map((worker) => {
|
|
1417
|
+
const terminalFailure = worker.status === "failed" || worker.status === "invalid_output" || worker.status === "timed_out";
|
|
1418
|
+
const workerTime = Date.parse(worker.completedAt ?? worker.startedAt);
|
|
1419
|
+
const olderThanCurrentRun = currentStartedAt !== undefined && !Number.isNaN(workerTime) && workerTime < currentStartedAt;
|
|
1420
|
+
const workerGate = input.gates.find((gate) =>
|
|
1421
|
+
TERMINAL_WORKER_GATE_KINDS.includes(gate.kind) &&
|
|
1422
|
+
isRecord(gate.details) &&
|
|
1423
|
+
gate.details.workerId === worker.id
|
|
1424
|
+
);
|
|
1425
|
+
if (terminalFailure && (worker.runId !== currentRunId || olderThanCurrentRun || workerGate?.activity === "historical")) {
|
|
1426
|
+
return { ...worker, activity: "historical" as const, activityReason: "stale_worker_failure" };
|
|
1427
|
+
}
|
|
1428
|
+
if (worker.runId === currentRunId && input.run && isActiveRun(input.run)) {
|
|
1429
|
+
return { ...worker, activity: "active" as const, activityReason: "current_run" };
|
|
1430
|
+
}
|
|
1431
|
+
return { ...worker, activity: "historical" as const, activityReason: "historical_run" };
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function isActiveRun(run: AgentLoopRun): boolean {
|
|
1436
|
+
return run.status === "RUNNING" || run.status === "BLOCKED";
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function historicalGateHandledIds(events: AgentLoopEvent[]): Set<string> {
|
|
1440
|
+
const ids = new Set<string>();
|
|
1441
|
+
for (const event of events) {
|
|
1442
|
+
if (event.kind !== HISTORICAL_EVENT_KIND || !isRecord(event.payload)) {
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
const gateId = event.payload.gateId;
|
|
1446
|
+
if (typeof gateId === "string") {
|
|
1447
|
+
ids.add(gateId);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return ids;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function reevaluationResult(activity: "active" | "historical" | undefined, activityReason: string | undefined): HistoricalGateReevaluationResult {
|
|
1454
|
+
if (activity === "active") {
|
|
1455
|
+
return "active_again";
|
|
1456
|
+
}
|
|
1457
|
+
if (activityReason === "marked_handled") {
|
|
1458
|
+
return "manually_handled";
|
|
1459
|
+
}
|
|
1460
|
+
if (activityReason === "overridden_by_reality") {
|
|
1461
|
+
return "overridden_by_current_reality";
|
|
1462
|
+
}
|
|
1463
|
+
return "still_historical";
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function workerState(value: string | undefined): AgentLoopState {
|
|
1467
|
+
if (value === "WRITE_SPEC" || value === "IMPLEMENT" || value === "FIX_REVIEW" || value === "SELF_CHECK") {
|
|
1468
|
+
return value;
|
|
1469
|
+
}
|
|
1470
|
+
return "SELF_CHECK";
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function likelyTouchedFiles(repoRoot: string, plansDir: string, selectedFile: string | undefined): string[] {
|
|
1474
|
+
const paths = new Set([plansDir, ".agent-loop/config.json"]);
|
|
1475
|
+
if (selectedFile) {
|
|
1476
|
+
paths.add(relative(repoRoot, selectedFile).replaceAll("\\", "/"));
|
|
1477
|
+
}
|
|
1478
|
+
return [...paths];
|
|
1479
|
+
}
|