@kodrunhq/opencode-autopilot 1.15.2 → 1.17.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/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +3 -3
- package/src/health/checks.ts +126 -4
- package/src/health/types.ts +1 -1
- package/src/index.ts +128 -13
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +65 -0
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +82 -67
- package/src/memory/database.ts +74 -12
- package/src/memory/decay.ts +11 -2
- package/src/memory/index.ts +17 -1
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/project-key.ts +6 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +52 -216
- package/src/memory/retrieval.ts +88 -170
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +69 -20
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +167 -0
- package/src/observability/forensic-schemas.ts +77 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +161 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +42 -219
- package/src/orchestrator/handlers/retrospective.ts +2 -2
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +36 -11
- package/src/orchestrator/orchestration-logger.ts +53 -24
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/progress.ts +63 -0
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +39 -11
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/doctor.ts +28 -4
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +38 -11
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +108 -90
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +46 -7
- package/src/tools/session-stats.ts +3 -2
- package/src/tools/summary.ts +43 -0
- package/src/utils/paths.ts +20 -1
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
|
@@ -1,93 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Writes complete session logs as JSON files with atomic write pattern
|
|
5
|
-
* (temp file + rename) to prevent corruption. Logs are stored in
|
|
6
|
-
* ~/.config/opencode/logs/ (user-scoped, not project-scoped per D-08, D-09).
|
|
7
|
-
*/
|
|
1
|
+
import { getProjectArtifactDir, getProjectRootFromArtifactDir } from "../utils/paths";
|
|
2
|
+
import { appendForensicEvent } from "./forensic-log";
|
|
3
|
+
import type { ForensicEvent } from "./forensic-types";
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import type { SessionEvent, SessionLog } from "./types";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Returns the global logs directory path.
|
|
19
|
-
* Logs are user-scoped, not project-scoped (D-08, D-09).
|
|
20
|
-
*/
|
|
21
|
-
export function getLogsDir(): string {
|
|
22
|
-
return join(getGlobalConfigDir(), "logs");
|
|
5
|
+
export interface WriteSessionInput {
|
|
6
|
+
readonly projectRoot: string;
|
|
7
|
+
readonly sessionId: string;
|
|
8
|
+
readonly startedAt: string;
|
|
9
|
+
readonly endedAt?: string | null;
|
|
10
|
+
readonly events: readonly ForensicEvent[];
|
|
23
11
|
}
|
|
24
12
|
|
|
25
|
-
|
|
26
|
-
* Input shape for writeSessionLog -- the in-memory data to persist.
|
|
27
|
-
*/
|
|
28
|
-
interface WriteSessionInput {
|
|
13
|
+
interface LegacyWriteSessionInput {
|
|
29
14
|
readonly sessionId: string;
|
|
30
15
|
readonly startedAt: string;
|
|
31
|
-
readonly
|
|
16
|
+
readonly endedAt?: string | null;
|
|
17
|
+
readonly events: readonly ForensicEvent[];
|
|
32
18
|
}
|
|
33
19
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
* Extracts decisions from decision-type events.
|
|
38
|
-
* Builds errorSummary by counting error events by errorType.
|
|
39
|
-
*/
|
|
40
|
-
export function convertToSessionLog(input: WriteSessionInput): SessionLog {
|
|
41
|
-
const decisions = input.events
|
|
42
|
-
.filter((e): e is SessionEvent & { type: "decision" } => e.type === "decision")
|
|
43
|
-
.map((e) => ({
|
|
44
|
-
timestamp: e.timestamp,
|
|
45
|
-
phase: e.phase,
|
|
46
|
-
agent: e.agent,
|
|
47
|
-
decision: e.decision,
|
|
48
|
-
rationale: e.rationale,
|
|
49
|
-
}));
|
|
50
|
-
|
|
51
|
-
const errorSummary: Record<string, number> = {};
|
|
52
|
-
for (const e of input.events) {
|
|
53
|
-
if (e.type === "error") {
|
|
54
|
-
errorSummary[e.errorType] = (errorSummary[e.errorType] ?? 0) + 1;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
schemaVersion: 1 as const,
|
|
60
|
-
sessionId: input.sessionId,
|
|
61
|
-
startedAt: input.startedAt,
|
|
62
|
-
endedAt: new Date().toISOString(),
|
|
63
|
-
events: [...input.events],
|
|
64
|
-
decisions,
|
|
65
|
-
errorSummary,
|
|
66
|
-
};
|
|
20
|
+
export function getLogsDir(projectRoot: string): string {
|
|
21
|
+
return getProjectArtifactDir(projectRoot);
|
|
67
22
|
}
|
|
68
23
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
24
|
+
export async function writeSessionLog(
|
|
25
|
+
input: WriteSessionInput | LegacyWriteSessionInput,
|
|
26
|
+
projectRootOrArtifactDir?: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const projectRoot =
|
|
29
|
+
"projectRoot" in input
|
|
30
|
+
? input.projectRoot
|
|
31
|
+
: getProjectRootFromArtifactDir(projectRootOrArtifactDir ?? process.cwd());
|
|
32
|
+
|
|
33
|
+
for (const event of input.events) {
|
|
34
|
+
appendForensicEvent(projectRoot, {
|
|
35
|
+
timestamp: event.timestamp,
|
|
36
|
+
projectRoot,
|
|
37
|
+
domain: event.domain,
|
|
38
|
+
runId: event.runId,
|
|
39
|
+
sessionId: event.sessionId,
|
|
40
|
+
parentSessionId: event.parentSessionId,
|
|
41
|
+
phase: event.phase,
|
|
42
|
+
dispatchId: event.dispatchId,
|
|
43
|
+
taskId: event.taskId,
|
|
44
|
+
agent: event.agent,
|
|
45
|
+
type: event.type,
|
|
46
|
+
code: event.code,
|
|
47
|
+
message: event.message,
|
|
48
|
+
payload: event.payload,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
93
51
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { isEnoentError } from "../utils/fs-helpers";
|
|
4
|
-
import {
|
|
4
|
+
import { getGlobalConfigDir } from "../utils/paths";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_RETENTION_DAYS = 30;
|
|
7
7
|
|
|
@@ -25,7 +25,7 @@ interface PruneResult {
|
|
|
25
25
|
* @returns Count of pruned files
|
|
26
26
|
*/
|
|
27
27
|
export async function pruneOldLogs(options?: PruneOptions): Promise<PruneResult> {
|
|
28
|
-
const logsDir = options?.logsDir ??
|
|
28
|
+
const logsDir = options?.logsDir ?? join(getGlobalConfigDir(), "projects");
|
|
29
29
|
const retentionDays = options?.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
30
30
|
const threshold = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
31
31
|
|
|
@@ -1,63 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { getGlobalConfigDir } from "../utils/paths";
|
|
5
|
-
import { sessionEventSchema } from "./schemas";
|
|
6
|
-
import type { SessionEvent } from "./types";
|
|
1
|
+
import { getProjectRootFromArtifactDir } from "../utils/paths";
|
|
2
|
+
import { appendForensicEvent, readForensicEvents } from "./forensic-log";
|
|
3
|
+
import type { ForensicEvent } from "./forensic-types";
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Logs are user-scoped, not project-scoped (D-08, D-09).
|
|
11
|
-
*/
|
|
12
|
-
export function getLogsDir(): string {
|
|
13
|
-
return join(getGlobalConfigDir(), "logs");
|
|
5
|
+
export function getLogsDir(projectRoot: string): string {
|
|
6
|
+
return projectRoot;
|
|
14
7
|
}
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
export async function logEvent(
|
|
10
|
+
event: ForensicEvent,
|
|
11
|
+
artifactDirOrProjectRoot?: string,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const projectRoot = artifactDirOrProjectRoot
|
|
14
|
+
? getProjectRootFromArtifactDir(artifactDirOrProjectRoot)
|
|
15
|
+
: event.projectRoot;
|
|
16
|
+
|
|
17
|
+
appendForensicEvent(projectRoot, {
|
|
18
|
+
timestamp: event.timestamp,
|
|
19
|
+
projectRoot,
|
|
20
|
+
domain: event.domain,
|
|
21
|
+
runId: event.runId,
|
|
22
|
+
sessionId: event.sessionId,
|
|
23
|
+
parentSessionId: event.parentSessionId,
|
|
24
|
+
phase: event.phase,
|
|
25
|
+
dispatchId: event.dispatchId,
|
|
26
|
+
taskId: event.taskId,
|
|
27
|
+
agent: event.agent,
|
|
28
|
+
type: event.type,
|
|
29
|
+
code: event.code,
|
|
30
|
+
message: event.message,
|
|
31
|
+
payload: event.payload,
|
|
32
|
+
});
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*/
|
|
31
|
-
export async function logEvent(event: SessionEvent, logsDir?: string): Promise<void> {
|
|
32
|
-
// Validate against schema -- throws on invalid events
|
|
33
|
-
const validated = sessionEventSchema.parse(event);
|
|
34
|
-
|
|
35
|
-
const dir = logsDir ?? getLogsDir();
|
|
36
|
-
await ensureDir(dir);
|
|
37
|
-
|
|
38
|
-
const logPath = getSessionLogPath(validated.sessionId, dir);
|
|
39
|
-
const line = `${JSON.stringify(validated)}\n`;
|
|
40
|
-
|
|
41
|
-
await appendFile(logPath, line, "utf-8");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Reads and parses all events from a session's JSONL log file.
|
|
46
|
-
* Returns an empty array if the session log does not exist.
|
|
47
|
-
*
|
|
48
|
-
* @param sessionId - The session ID to read logs for
|
|
49
|
-
* @param logsDir - Optional override for the logs directory (for testing)
|
|
50
|
-
*/
|
|
51
|
-
export async function getSessionLog(sessionId: string, logsDir?: string): Promise<SessionEvent[]> {
|
|
52
|
-
const logPath = getSessionLogPath(sessionId, logsDir);
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const content = await readFile(logPath, "utf-8");
|
|
56
|
-
const lines = content.trim().split("\n").filter(Boolean);
|
|
57
|
-
|
|
58
|
-
return lines.map((line) => sessionEventSchema.parse(JSON.parse(line)));
|
|
59
|
-
} catch (error: unknown) {
|
|
60
|
-
if (isEnoentError(error)) return [];
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
35
|
+
export async function getSessionLog(
|
|
36
|
+
sessionId: string,
|
|
37
|
+
artifactDirOrProjectRoot: string,
|
|
38
|
+
): Promise<readonly ForensicEvent[]> {
|
|
39
|
+
const projectRoot = getProjectRootFromArtifactDir(artifactDirOrProjectRoot);
|
|
40
|
+
const events = await readForensicEvents(projectRoot);
|
|
41
|
+
return Object.freeze(events.filter((event) => event.sessionId === sessionId));
|
|
63
42
|
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session summary generator.
|
|
3
|
-
*
|
|
4
|
-
* Transforms structured SessionLog data into human-readable markdown summaries
|
|
5
|
-
* for post-session analysis. All functions are pure (no I/O, no side effects).
|
|
6
|
-
*
|
|
7
|
-
* Summaries include: metadata, decisions, errors, fallbacks, model switches,
|
|
8
|
-
* and a strategic one-paragraph analysis (D-03, D-11, D-42, D-43).
|
|
2
|
+
* Session summary generator over the unified forensic event stream.
|
|
9
3
|
*/
|
|
10
4
|
|
|
11
|
-
import type {
|
|
5
|
+
import type { ForensicEvent, SessionLog } from "./types";
|
|
12
6
|
|
|
13
7
|
/**
|
|
14
8
|
* Computes session duration in milliseconds from startedAt/endedAt.
|
|
@@ -61,7 +55,7 @@ export function generateSessionSummary(log: SessionLog): string {
|
|
|
61
55
|
|
|
62
56
|
// Errors section (conditional)
|
|
63
57
|
const errorEvents = log.events.filter(
|
|
64
|
-
(e): e is
|
|
58
|
+
(e): e is ForensicEvent & { type: "error" } => e.type === "error",
|
|
65
59
|
);
|
|
66
60
|
if (errorEvents.length > 0) {
|
|
67
61
|
sections.push(renderErrors(errorEvents, log.errorSummary));
|
|
@@ -69,7 +63,7 @@ export function generateSessionSummary(log: SessionLog): string {
|
|
|
69
63
|
|
|
70
64
|
// Fallbacks section (conditional)
|
|
71
65
|
const fallbackEvents = log.events.filter(
|
|
72
|
-
(e): e is
|
|
66
|
+
(e): e is ForensicEvent & { type: "fallback" } => e.type === "fallback",
|
|
73
67
|
);
|
|
74
68
|
if (fallbackEvents.length > 0) {
|
|
75
69
|
sections.push(renderFallbacks(fallbackEvents));
|
|
@@ -77,7 +71,7 @@ export function generateSessionSummary(log: SessionLog): string {
|
|
|
77
71
|
|
|
78
72
|
// Model switches section (conditional)
|
|
79
73
|
const switchEvents = log.events.filter(
|
|
80
|
-
(e): e is
|
|
74
|
+
(e): e is ForensicEvent & { type: "model_switch" } => e.type === "model_switch",
|
|
81
75
|
);
|
|
82
76
|
if (switchEvents.length > 0) {
|
|
83
77
|
sections.push(renderModelSwitches(switchEvents));
|
|
@@ -121,7 +115,7 @@ function renderDecisions(log: SessionLog): string {
|
|
|
121
115
|
}
|
|
122
116
|
|
|
123
117
|
function renderErrors(
|
|
124
|
-
events: readonly (
|
|
118
|
+
events: readonly (ForensicEvent & { type: "error" })[],
|
|
125
119
|
errorSummary: Record<string, number>,
|
|
126
120
|
): string {
|
|
127
121
|
const lines = [
|
|
@@ -133,8 +127,13 @@ function renderErrors(
|
|
|
133
127
|
|
|
134
128
|
for (const e of events) {
|
|
135
129
|
const safeMsg = (e.message ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
136
|
-
const safeModel = (e.model
|
|
137
|
-
|
|
130
|
+
const safeModel = (typeof e.payload.model === "string" ? e.payload.model : "").replace(
|
|
131
|
+
/\|/g,
|
|
132
|
+
"\\|",
|
|
133
|
+
);
|
|
134
|
+
const errorCode =
|
|
135
|
+
e.code ?? (typeof e.payload.errorType === "string" ? e.payload.errorType : "unknown");
|
|
136
|
+
lines.push(`| ${e.timestamp} | ${errorCode} | ${safeModel} | ${safeMsg} |`);
|
|
138
137
|
}
|
|
139
138
|
|
|
140
139
|
// Error summary counts
|
|
@@ -150,7 +149,7 @@ function renderErrors(
|
|
|
150
149
|
return lines.join("\n");
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
function renderFallbacks(events: readonly (
|
|
152
|
+
function renderFallbacks(events: readonly (ForensicEvent & { type: "fallback" })[]): string {
|
|
154
153
|
const lines = [
|
|
155
154
|
"## Fallbacks",
|
|
156
155
|
"",
|
|
@@ -159,15 +158,23 @@ function renderFallbacks(events: readonly (SessionEvent & { type: "fallback" })[
|
|
|
159
158
|
];
|
|
160
159
|
|
|
161
160
|
for (const e of events) {
|
|
161
|
+
const failedModel =
|
|
162
|
+
typeof e.payload.failedModel === "string" ? e.payload.failedModel : "unknown";
|
|
163
|
+
const nextModel = typeof e.payload.nextModel === "string" ? e.payload.nextModel : "unknown";
|
|
164
|
+
const reason =
|
|
165
|
+
typeof e.payload.reason === "string" ? e.payload.reason : (e.message ?? "unknown");
|
|
166
|
+
const success = e.payload.success === true;
|
|
162
167
|
lines.push(
|
|
163
|
-
`| ${e.timestamp} | ${
|
|
168
|
+
`| ${e.timestamp} | ${failedModel} | ${nextModel} | ${reason} | ${success ? "Yes" : "No"} |`,
|
|
164
169
|
);
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
return lines.join("\n");
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
function renderModelSwitches(
|
|
175
|
+
function renderModelSwitches(
|
|
176
|
+
events: readonly (ForensicEvent & { type: "model_switch" })[],
|
|
177
|
+
): string {
|
|
171
178
|
const lines = [
|
|
172
179
|
"## Model Switches",
|
|
173
180
|
"",
|
|
@@ -176,7 +183,10 @@ function renderModelSwitches(events: readonly (SessionEvent & { type: "model_swi
|
|
|
176
183
|
];
|
|
177
184
|
|
|
178
185
|
for (const e of events) {
|
|
179
|
-
|
|
186
|
+
const fromModel = typeof e.payload.fromModel === "string" ? e.payload.fromModel : "unknown";
|
|
187
|
+
const toModel = typeof e.payload.toModel === "string" ? e.payload.toModel : "unknown";
|
|
188
|
+
const trigger = typeof e.payload.trigger === "string" ? e.payload.trigger : "unknown";
|
|
189
|
+
lines.push(`| ${e.timestamp} | ${fromModel} | ${toModel} | ${trigger} |`);
|
|
180
190
|
}
|
|
181
191
|
|
|
182
192
|
return lines.join("\n");
|
|
@@ -194,7 +204,9 @@ function renderStrategicSummary(log: SessionLog): string {
|
|
|
194
204
|
);
|
|
195
205
|
|
|
196
206
|
if (errorCount > 0) {
|
|
197
|
-
const successfulFallbacks = log.events.filter(
|
|
207
|
+
const successfulFallbacks = log.events.filter(
|
|
208
|
+
(e) => e.type === "fallback" && e.payload.success === true,
|
|
209
|
+
).length;
|
|
198
210
|
parts.push(
|
|
199
211
|
`Encountered ${errorCount} errors; ${fallbackCount} fallback attempts (${successfulFallbacks} successful).`,
|
|
200
212
|
);
|
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./
|
|
13
|
-
|
|
14
|
-
export type SessionEventType = "fallback" | "error" | "decision" | "model_switch";
|
|
15
|
-
|
|
16
|
-
export type BaseEvent = z.infer<typeof baseEventSchema>;
|
|
17
|
-
export type FallbackEvent = z.infer<typeof fallbackEventSchema>;
|
|
18
|
-
export type ErrorEvent = z.infer<typeof errorEventSchema>;
|
|
19
|
-
export type DecisionEvent = z.infer<typeof decisionEventSchema>;
|
|
20
|
-
export type ModelSwitchEvent = z.infer<typeof modelSwitchEventSchema>;
|
|
21
|
-
export type SessionEvent = z.infer<typeof sessionEventSchema>;
|
|
22
|
-
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
|
|
23
|
-
export type SessionDecision = z.infer<typeof sessionDecisionSchema>;
|
|
24
|
-
export type SessionLog = z.infer<typeof sessionLogSchema>;
|
|
1
|
+
export type {
|
|
2
|
+
ForensicEvent,
|
|
3
|
+
ForensicEvent as SessionEvent,
|
|
4
|
+
ForensicEventDomain,
|
|
5
|
+
ForensicEventType,
|
|
6
|
+
} from "./forensic-types";
|
|
7
|
+
export type {
|
|
8
|
+
EventSearchFilters,
|
|
9
|
+
SessionDecision,
|
|
10
|
+
SessionLog,
|
|
11
|
+
SessionLogEntry,
|
|
12
|
+
} from "./log-reader";
|
|
@@ -33,6 +33,13 @@ function collectPendingDispatchKeys(state: Readonly<PipelineState>): readonly st
|
|
|
33
33
|
return state.pendingDispatches.map((entry) => `${entry.dispatchId}|${entry.phase}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function hasPendingDispatchOutsideCurrentPhase(state: Readonly<PipelineState>): boolean {
|
|
37
|
+
if (state.currentPhase === null) {
|
|
38
|
+
return state.pendingDispatches.length > 0;
|
|
39
|
+
}
|
|
40
|
+
return state.pendingDispatches.some((entry) => entry.phase !== state.currentPhase);
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export function validateStateInvariants(
|
|
37
44
|
state: Readonly<PipelineState>,
|
|
38
45
|
): readonly InvariantViolation[] {
|
|
@@ -93,6 +100,13 @@ export function validateStateInvariants(
|
|
|
93
100
|
});
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
if (hasPendingDispatchOutsideCurrentPhase(state)) {
|
|
104
|
+
violations.push({
|
|
105
|
+
code: "E_INVARIANT_PENDING_PHASE",
|
|
106
|
+
message: "pendingDispatches must belong to the current active phase only",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
const processedIds = state.processedResultIds;
|
|
97
111
|
if (new Set(processedIds).size !== processedIds.length) {
|
|
98
112
|
violations.push({
|
|
@@ -2,20 +2,19 @@ import { z } from "zod";
|
|
|
2
2
|
import type { Phase } from "../types";
|
|
3
3
|
import { type ResultEnvelope, resultEnvelopeSchema } from "./result-envelope";
|
|
4
4
|
|
|
5
|
-
export interface
|
|
5
|
+
export interface ParseTypedResultEnvelopeResult {
|
|
6
6
|
readonly envelope: ResultEnvelope;
|
|
7
|
-
readonly legacy: boolean;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
export function
|
|
9
|
+
export function parseTypedResultEnvelope(
|
|
11
10
|
raw: string,
|
|
12
|
-
|
|
11
|
+
_ctx: {
|
|
13
12
|
readonly runId: string;
|
|
14
13
|
readonly phase: Phase;
|
|
15
14
|
readonly fallbackDispatchId: string;
|
|
16
15
|
readonly fallbackAgent?: string | null;
|
|
17
16
|
},
|
|
18
|
-
):
|
|
17
|
+
): ParseTypedResultEnvelopeResult {
|
|
19
18
|
const trimmed = raw.trim();
|
|
20
19
|
if (trimmed.length === 0) {
|
|
21
20
|
throw new Error("E_INVALID_RESULT: empty result payload");
|
|
@@ -24,24 +23,13 @@ export function parseResultEnvelope(
|
|
|
24
23
|
try {
|
|
25
24
|
const parsed = JSON.parse(trimmed);
|
|
26
25
|
const envelope = resultEnvelopeSchema.parse(parsed);
|
|
27
|
-
return { envelope
|
|
26
|
+
return { envelope };
|
|
28
27
|
} catch (error: unknown) {
|
|
29
28
|
if (error instanceof z.ZodError) {
|
|
30
29
|
throw new Error(`E_INVALID_RESULT: ${error.issues[0]?.message ?? "invalid envelope"}`);
|
|
31
30
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
runId: ctx.runId,
|
|
36
|
-
phase: ctx.phase,
|
|
37
|
-
dispatchId: ctx.fallbackDispatchId,
|
|
38
|
-
agent: ctx.fallbackAgent ?? null,
|
|
39
|
-
kind: "phase_output",
|
|
40
|
-
taskId: null,
|
|
41
|
-
payload: {
|
|
42
|
-
text: raw,
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
return { envelope: legacyEnvelope, legacy: true };
|
|
31
|
+
throw new Error(
|
|
32
|
+
"E_INVALID_RESULT: Result payload must be a typed result envelope JSON object.",
|
|
33
|
+
);
|
|
46
34
|
}
|
|
47
35
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PipelineState } from "./types";
|
|
2
|
+
|
|
3
|
+
export function enrichErrorMessage(error: string, state: PipelineState): string {
|
|
4
|
+
const phase = state.currentPhase ?? "UNKNOWN";
|
|
5
|
+
const details: string[] = [];
|
|
6
|
+
|
|
7
|
+
if (
|
|
8
|
+
state.currentPhase === "BUILD" &&
|
|
9
|
+
state.buildProgress?.currentWave !== null &&
|
|
10
|
+
state.buildProgress?.currentWave !== undefined
|
|
11
|
+
) {
|
|
12
|
+
details.push(`wave ${state.buildProgress.currentWave}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (state.buildProgress?.currentTask !== null && state.buildProgress?.currentTask !== undefined) {
|
|
16
|
+
const task = state.tasks.find((entry) => entry.id === state.buildProgress.currentTask);
|
|
17
|
+
details.push(
|
|
18
|
+
task ? `task ${task.id}: ${task.title}` : `task ${state.buildProgress.currentTask}`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const context = details.length > 0 ? ` (${details.join(", ")})` : "";
|
|
23
|
+
return `Error in phase ${phase}${context}: ${error}`;
|
|
24
|
+
}
|
|
@@ -26,6 +26,17 @@ export interface EventHandlerDeps {
|
|
|
26
26
|
readonly manager: FallbackManager;
|
|
27
27
|
readonly sdk: SdkOperations;
|
|
28
28
|
readonly config: FallbackConfig;
|
|
29
|
+
readonly onFallbackEvent?: (event: {
|
|
30
|
+
readonly type: "fallback" | "model_switch";
|
|
31
|
+
readonly sessionId: string;
|
|
32
|
+
readonly failedModel?: string;
|
|
33
|
+
readonly nextModel?: string;
|
|
34
|
+
readonly reason?: string;
|
|
35
|
+
readonly success?: boolean;
|
|
36
|
+
readonly fromModel?: string;
|
|
37
|
+
readonly toModel?: string;
|
|
38
|
+
readonly trigger?: "fallback" | "config" | "user";
|
|
39
|
+
}) => void;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/**
|
|
@@ -64,7 +75,7 @@ function extractSessionID(properties: Record<string, unknown>): string | undefin
|
|
|
64
75
|
* The returned handler routes OpenCode events to the FallbackManager.
|
|
65
76
|
*/
|
|
66
77
|
export function createEventHandler(deps: EventHandlerDeps) {
|
|
67
|
-
const { manager, sdk, config } = deps;
|
|
78
|
+
const { manager, sdk, config, onFallbackEvent } = deps;
|
|
68
79
|
|
|
69
80
|
return async (input: {
|
|
70
81
|
readonly event: { readonly type: string; readonly [key: string]: unknown };
|
|
@@ -133,7 +144,15 @@ export function createEventHandler(deps: EventHandlerDeps) {
|
|
|
133
144
|
const error = properties.error;
|
|
134
145
|
const modelStr = typeof properties.model === "string" ? properties.model : undefined;
|
|
135
146
|
|
|
136
|
-
await handleFallbackError(
|
|
147
|
+
await handleFallbackError(
|
|
148
|
+
manager,
|
|
149
|
+
sdk,
|
|
150
|
+
config,
|
|
151
|
+
sessionID,
|
|
152
|
+
error,
|
|
153
|
+
modelStr,
|
|
154
|
+
onFallbackEvent,
|
|
155
|
+
);
|
|
137
156
|
return;
|
|
138
157
|
}
|
|
139
158
|
|
|
@@ -144,7 +163,15 @@ export function createEventHandler(deps: EventHandlerDeps) {
|
|
|
144
163
|
if (!info?.sessionID || !info.error) return;
|
|
145
164
|
|
|
146
165
|
const modelStr = typeof info.model === "string" ? info.model : undefined;
|
|
147
|
-
await handleFallbackError(
|
|
166
|
+
await handleFallbackError(
|
|
167
|
+
manager,
|
|
168
|
+
sdk,
|
|
169
|
+
config,
|
|
170
|
+
info.sessionID,
|
|
171
|
+
info.error,
|
|
172
|
+
modelStr,
|
|
173
|
+
onFallbackEvent,
|
|
174
|
+
);
|
|
148
175
|
return;
|
|
149
176
|
}
|
|
150
177
|
|
|
@@ -165,6 +192,7 @@ async function handleFallbackError(
|
|
|
165
192
|
sessionID: string,
|
|
166
193
|
error: unknown,
|
|
167
194
|
modelStr?: string,
|
|
195
|
+
onFallbackEvent?: EventHandlerDeps["onFallbackEvent"],
|
|
168
196
|
): Promise<void> {
|
|
169
197
|
// All guards (self-abort, stale, retryable, lock) are inside manager.handleError
|
|
170
198
|
const plan = manager.handleError(sessionID, error, modelStr);
|
|
@@ -220,6 +248,22 @@ async function handleFallbackError(
|
|
|
220
248
|
});
|
|
221
249
|
}
|
|
222
250
|
|
|
251
|
+
onFallbackEvent?.({
|
|
252
|
+
type: "fallback",
|
|
253
|
+
sessionId: sessionID,
|
|
254
|
+
failedModel: plan.failedModel,
|
|
255
|
+
nextModel: plan.newModel,
|
|
256
|
+
reason: plan.reason,
|
|
257
|
+
success: true,
|
|
258
|
+
});
|
|
259
|
+
onFallbackEvent?.({
|
|
260
|
+
type: "model_switch",
|
|
261
|
+
sessionId: sessionID,
|
|
262
|
+
fromModel: plan.failedModel,
|
|
263
|
+
toModel: plan.newModel,
|
|
264
|
+
trigger: "fallback",
|
|
265
|
+
});
|
|
266
|
+
|
|
223
267
|
// Dispatch replay with fallback model
|
|
224
268
|
await sdk.promptAsync(sessionID, parsedModel, replayedParts);
|
|
225
269
|
// Mark awaiting result inside dispatch block — only when prompt was sent
|
|
@@ -2,6 +2,7 @@ import { readdir } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { sanitizeTemplateContent } from "../../review/sanitize";
|
|
4
4
|
import { fileExists } from "../../utils/fs-helpers";
|
|
5
|
+
import { getProjectRootFromArtifactDir } from "../../utils/paths";
|
|
5
6
|
import { getMemoryTunedDepth } from "../arena";
|
|
6
7
|
import { ensurePhaseDir, getArtifactRef, getPhaseDir } from "../artifacts";
|
|
7
8
|
import { filterByPhase } from "../confidence";
|
|
@@ -67,7 +68,7 @@ export async function handleArchitect(
|
|
|
67
68
|
// Step 1: Dispatch architect(s) based on confidence depth
|
|
68
69
|
await ensurePhaseDir(artifactDir, "ARCHITECT");
|
|
69
70
|
const reconEntries = filterByPhase(state.confidence, "RECON");
|
|
70
|
-
const depth = getMemoryTunedDepth(reconEntries,
|
|
71
|
+
const depth = getMemoryTunedDepth(reconEntries, getProjectRootFromArtifactDir(artifactDir));
|
|
71
72
|
const reconRef = getArtifactRef(artifactDir, "RECON", "report.md");
|
|
72
73
|
const challengeRef = getArtifactRef(artifactDir, "CHALLENGE", "brief.md");
|
|
73
74
|
const safeIdea = sanitizeTemplateContent(state.idea).replace(/[\r\n]+/g, " ");
|