@yaag/runtime 0.1.2 → 0.2.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.
Files changed (44) hide show
  1. package/package.json +3 -3
  2. package/src/agent.ts +11 -3
  3. package/src/ask-contract-identity.ts +23 -5
  4. package/src/ask-exchange-events.ts +14 -1
  5. package/src/ask-exchange-options.ts +7 -2
  6. package/src/ask-exchange.ts +198 -26
  7. package/src/ask-hash.ts +7 -3
  8. package/src/ask-limit.ts +27 -11
  9. package/src/ask-output-steering.ts +1 -3
  10. package/src/ask-output.ts +20 -4
  11. package/src/ask-turn.ts +11 -0
  12. package/src/cassette-loader.ts +24 -0
  13. package/src/cassette-replay.ts +76 -7
  14. package/src/cassette-schema.ts +17 -2
  15. package/src/cassette.ts +10 -9
  16. package/src/checkpoint-flush.ts +101 -0
  17. package/src/connection.ts +20 -0
  18. package/src/define-agent.ts +11 -5
  19. package/src/errors.ts +11 -2
  20. package/src/events.ts +29 -3
  21. package/src/fake-transport.ts +55 -0
  22. package/src/frame-queue.ts +5 -0
  23. package/src/index.ts +11 -1
  24. package/src/model-resolution.ts +119 -0
  25. package/src/model-suffix.ts +24 -0
  26. package/src/pi-state.ts +63 -8
  27. package/src/recording-transport.ts +8 -8
  28. package/src/replay-divergence.ts +1 -0
  29. package/src/replay-transport.ts +4 -0
  30. package/src/report-result-extension.ts +128 -0
  31. package/src/report-result-output.ts +73 -0
  32. package/src/report-result-steering.ts +83 -0
  33. package/src/report-result.ts +122 -0
  34. package/src/resume-transport.ts +26 -8
  35. package/src/run-checkpoint.ts +93 -41
  36. package/src/run.ts +86 -32
  37. package/src/spawn.ts +29 -4
  38. package/src/stall-watchdog.ts +193 -0
  39. package/src/summary-agent.ts +11 -2
  40. package/src/summary.ts +23 -2
  41. package/src/thinking-level.ts +32 -0
  42. package/src/transport.ts +43 -8
  43. package/src/types.ts +50 -14
  44. package/src/validation-errors.ts +10 -4
@@ -0,0 +1,128 @@
1
+ /**
2
+ * yaag's private structured-result tool, loaded into every Agent at spawn.
3
+ *
4
+ * The tool stays inactive until a schema-bearing Ask delivers its output schema
5
+ * through the `yaag-report-result` command; the schema becomes the tool's input
6
+ * schema, so pi validates the arguments and the Orchestrator settles the Ask
7
+ * from the call instead of scraping final text (ADR-0032).
8
+ *
9
+ * Types are declared locally rather than imported from pi: this file is passed
10
+ * to `pi -e` as a path and must load without yaag's dependency graph.
11
+ */
12
+
13
+ /** One tool result, in the shape pi's tool layer returns to the model. */
14
+ export interface ReportResultToolResult {
15
+ readonly content: readonly { readonly type: "text"; readonly text: string }[];
16
+ readonly details?: { readonly reportedResult: unknown };
17
+ readonly isError?: boolean;
18
+ readonly terminate?: boolean;
19
+ }
20
+
21
+ /** One tool definition, in the shape `pi.registerTool` accepts. */
22
+ export interface ReportResultToolDefinition {
23
+ readonly name: string;
24
+ readonly label: string;
25
+ readonly description: string;
26
+ readonly promptSnippet: string;
27
+ readonly promptGuidelines: readonly string[];
28
+ readonly parameters: object;
29
+ execute(toolCallId: string, params: unknown): Promise<ReportResultToolResult>;
30
+ }
31
+
32
+ /** The part of pi's extension API this extension uses (pi docs/extensions.md). */
33
+ export interface ReportResultAPI {
34
+ registerTool(definition: ReportResultToolDefinition): void;
35
+ registerCommand(
36
+ name: string,
37
+ command: {
38
+ readonly description: string;
39
+ handler(args: string): void;
40
+ },
41
+ ): void;
42
+ getActiveTools(): string[];
43
+ setActiveTools(names: readonly string[]): void;
44
+ }
45
+
46
+ const TOOL_NAME = "report_result";
47
+ const COMMAND_NAME = "yaag-report-result";
48
+ const DESCRIPTION = [
49
+ "Report the final structured result of the current task.",
50
+ "Call report_result exactly once, with the complete result as its arguments.",
51
+ "Do not send the result as text.",
52
+ ].join(" ");
53
+ const ALREADY_REPORTED = "result already reported";
54
+ /**
55
+ * Guidelines name the tool, because pi appends them flat to one Guidelines
56
+ * section where "this tool" cannot be resolved (pi docs/extensions.md).
57
+ */
58
+ const GUIDELINES = [
59
+ "End every task by calling report_result exactly once with the complete final result.",
60
+ "Write your prose answer first if you want one, then call report_result: prose alone does not" +
61
+ " deliver the result.",
62
+ ];
63
+
64
+ export default function (pi: ReportResultAPI): void {
65
+ let reported = false;
66
+
67
+ const register = (schema: object): void => {
68
+ pi.registerTool({
69
+ name: TOOL_NAME,
70
+ label: "Report Result",
71
+ description: DESCRIPTION,
72
+ promptSnippet: "Report the final structured result of the current task",
73
+ promptGuidelines: GUIDELINES,
74
+ parameters: schema,
75
+ execute: (_toolCallId, params): Promise<ReportResultToolResult> => {
76
+ if (reported) {
77
+ return Promise.resolve({
78
+ content: [{ type: "text", text: ALREADY_REPORTED }],
79
+ isError: true,
80
+ });
81
+ }
82
+ reported = true;
83
+ // `details` carries the arguments pi validated, which differ from the
84
+ // arguments on `tool_execution_start` when pi coerces a value. It is
85
+ // what the Orchestrator settles the Ask with, live and in replay.
86
+ // `terminate` ends the turn on the call itself, so no extra assistant
87
+ // turn is paid for (ADR-0032).
88
+ return Promise.resolve({
89
+ content: [{ type: "text", text: reportedText(params) }],
90
+ details: { reportedResult: params },
91
+ terminate: true,
92
+ });
93
+ },
94
+ });
95
+ };
96
+
97
+ pi.registerCommand(COMMAND_NAME, {
98
+ description: "Internal yaag command: sets the structured-result schema of one Ask",
99
+ handler: (args): void => {
100
+ const schema = parseSchema(args);
101
+ if (schema === null) {
102
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== TOOL_NAME));
103
+ return;
104
+ }
105
+ reported = false;
106
+ register(schema);
107
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), TOOL_NAME])]);
108
+ },
109
+ });
110
+ }
111
+
112
+ /** Null deactivates the tool; a schema object activates it for the next Ask. */
113
+ function parseSchema(args: string): object | null {
114
+ const payload: unknown = JSON.parse(args);
115
+ if (typeof payload !== "object" || payload === null || !("schema" in payload)) {
116
+ throw new Error("yaag-report-result requires a JSON payload with a schema field");
117
+ }
118
+ const schema: unknown = payload.schema;
119
+ if (schema === null) return null;
120
+ if (typeof schema !== "object" || Array.isArray(schema)) {
121
+ throw new Error("yaag-report-result schema must be a JSON schema object or null");
122
+ }
123
+ return schema;
124
+ }
125
+
126
+ function reportedText(params: unknown): string {
127
+ return `Result reported: ${JSON.stringify(params)}`;
128
+ }
@@ -0,0 +1,73 @@
1
+ import type { TSchema } from "typebox";
2
+ import { Value } from "typebox/value";
3
+ import type { AskOutputResult } from "./ask-output.ts";
4
+ import { invalidAskOutputError } from "./ask-output.ts";
5
+ import type { ReportedResult } from "./report-result.ts";
6
+ import type { AskInvalidOutputPlayback } from "./transport.ts";
7
+ import { formatValidationErrors } from "./validation-errors.ts";
8
+
9
+ /** The one correction an Agent gets when it settles without reporting a result. */
10
+ export const REPORT_RESULT_CORRECTION =
11
+ "You did not report a result. Call the report_result tool exactly once with the final" +
12
+ " result. Do not send the result as text.";
13
+
14
+ /** Appended to a limit wrap-up so the Agent still ends on a reported result. */
15
+ export const REPORT_RESULT_WRAP_UP =
16
+ "End by calling report_result exactly once with the final result.";
17
+
18
+ /** What `ASK_INVALID_OUTPUT` carries when no `report_result` call ever arrived. */
19
+ export const REPORT_RESULT_MISSING_ERRORS: readonly string[] = [
20
+ "$: agent did not call report_result",
21
+ ];
22
+
23
+ /** What a rethrown recorded failure carries when its recorded value now validates. */
24
+ export const REPORT_RESULT_RECORDED_FAILURE_ERRORS: readonly string[] = [
25
+ "$: the recorded Ask produced no valid result",
26
+ ];
27
+
28
+ /**
29
+ * Validates one reported result against the Ask's schema, without coercion.
30
+ *
31
+ * pi validates and coerces the call arguments first, so a failure here means
32
+ * the recorded or reported value does not match the schema the Ask declared.
33
+ */
34
+ export function validateReportedResult(value: unknown, schema: TSchema): AskOutputResult {
35
+ const errors = formatValidationErrors(value, Value.Errors(schema, value));
36
+ return errors.length === 0 ? { ok: true, value } : { ok: false, errors };
37
+ }
38
+
39
+ /** One recorded structured Ask, as playback resolves it. */
40
+ export interface PlaybackReportedResult {
41
+ readonly agent: string;
42
+ readonly schema: TSchema;
43
+ /** The call the Cassette recorded, absent when the Ask reported nothing. */
44
+ readonly reported: ReportedResult | undefined;
45
+ /** The invalid-output outcome the recorded Ask surfaced, when it surfaced one. */
46
+ readonly recordedOutcome: AskInvalidOutputPlayback | undefined;
47
+ }
48
+
49
+ /**
50
+ * Resolves a replayed structured Ask from the `report_result` call in its frames.
51
+ *
52
+ * A recorded invalid-output outcome rethrows deterministically, exactly as the
53
+ * recorded Run failed (ADR-0032). Otherwise the recorded call's value is
54
+ * re-validated, so playback and live settlement agree. Throws the same
55
+ * recoverable `ASK_INVALID_OUTPUT` error as a live Ask.
56
+ */
57
+ export function resolvePlaybackReportedResult(playback: PlaybackReportedResult): unknown {
58
+ const { agent, schema, reported, recordedOutcome } = playback;
59
+ const result =
60
+ reported === undefined
61
+ ? { ok: false as const, errors: REPORT_RESULT_MISSING_ERRORS }
62
+ : validateReportedResult(reported.value, schema);
63
+ if (recordedOutcome !== undefined) {
64
+ throw invalidAskOutputError(agent, {
65
+ steeringEfforts: recordedOutcome.steeringEfforts,
66
+ // The Cassette records the outcome, never its messages, so a recorded
67
+ // failure whose value now validates still needs a diagnostic.
68
+ errors: result.ok ? REPORT_RESULT_RECORDED_FAILURE_ERRORS : result.errors,
69
+ });
70
+ }
71
+ if (result.ok) return result.value;
72
+ throw invalidAskOutputError(agent, { steeringEfforts: 0, errors: result.errors });
73
+ }
@@ -0,0 +1,83 @@
1
+ import type { TSchema } from "typebox";
2
+ import { DEFAULT_MAX_STEERS, invalidAskOutputError } from "./ask-output.ts";
3
+ import type { ReportedResult } from "./report-result.ts";
4
+ import {
5
+ REPORT_RESULT_CORRECTION,
6
+ REPORT_RESULT_MISSING_ERRORS,
7
+ validateReportedResult,
8
+ } from "./report-result-output.ts";
9
+
10
+ /** What one Ask's report-result settlement needs from its exchange. */
11
+ export interface ReportResultSteeringOptions {
12
+ readonly agent: string;
13
+ readonly schema: TSchema;
14
+ readonly maxSteers: number | undefined;
15
+ /** The accepted `report_result` call of the settled turn, when there is one. */
16
+ readonly reported: () => ReportedResult | undefined;
17
+ /** Atomically enters a correction effort, or false after a limit starts wrap-up. */
18
+ readonly beginCorrection: () => boolean;
19
+ /** True once a soft limit has sent its wrap-up steer. */
20
+ readonly wrappingUp: () => boolean;
21
+ /** Re-arms settlement tracking, sends the correction, and waits for its settlement. */
22
+ readonly correct: (message: string) => Promise<void>;
23
+ /** Re-arms settlement tracking, sends abort, and waits for its settlement. */
24
+ readonly abort: () => Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Settles a schema-bearing Ask from its `report_result` call (ADR-0032).
29
+ *
30
+ * Only a settlement without a valid call costs a steering effort: repairing
31
+ * invalid arguments is pi's own tool-retry loop, bounded by the Ask's outer
32
+ * envelope. A reported value that still fails validation cannot be repaired by
33
+ * a correction prompt, so it fails the Ask directly.
34
+ *
35
+ * Control-command failures propagate from `correct` or `abort` as Agent
36
+ * failures, rather than being reported as invalid-output exhaustion.
37
+ */
38
+ export class ReportResultSteering {
39
+ readonly #options: ReportResultSteeringOptions;
40
+ readonly #maxSteers: number;
41
+
42
+ constructor(options: ReportResultSteeringOptions) {
43
+ this.#options = options;
44
+ this.#maxSteers = options.maxSteers ?? DEFAULT_MAX_STEERS;
45
+ }
46
+
47
+ /** Resolves the validated reported value, or rejects after the abort settlement. */
48
+ async resolve(): Promise<unknown> {
49
+ let efforts = 0;
50
+ for (;;) {
51
+ const reported = this.#options.reported();
52
+ if (reported !== undefined) return await this.#settle(reported, efforts);
53
+ if (efforts >= this.#maxSteers) break;
54
+ if (!this.#options.beginCorrection()) throw this.#invalidAfterWrapUp();
55
+ efforts += 1;
56
+ await this.#options.correct(REPORT_RESULT_CORRECTION);
57
+ }
58
+ if (this.#options.wrappingUp()) throw this.#invalidAfterWrapUp();
59
+ await this.#options.abort();
60
+ throw this.#invalid(efforts, REPORT_RESULT_MISSING_ERRORS);
61
+ }
62
+
63
+ async #settle(reported: ReportedResult, efforts: number): Promise<unknown> {
64
+ const result = validateReportedResult(reported.value, this.#options.schema);
65
+ if (result.ok) return result.value;
66
+ if (this.#options.wrappingUp()) {
67
+ throw invalidAskOutputError(this.#options.agent, {
68
+ steeringEfforts: 0,
69
+ errors: result.errors,
70
+ });
71
+ }
72
+ await this.#options.abort();
73
+ throw this.#invalid(efforts, result.errors);
74
+ }
75
+
76
+ #invalid(steeringEfforts: number, errors: readonly string[]): Error {
77
+ return invalidAskOutputError(this.#options.agent, { steeringEfforts, errors });
78
+ }
79
+
80
+ #invalidAfterWrapUp(): Error {
81
+ return this.#invalid(0, REPORT_RESULT_MISSING_ERRORS);
82
+ }
83
+ }
@@ -0,0 +1,122 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { CanonicalJsonObject } from "./ask-contract-identity.ts";
3
+ import type { Frame } from "./transport.ts";
4
+
5
+ /** The internal tool a schema-bearing Ask settles from (ADR-0032). */
6
+ export const REPORT_RESULT_TOOL_NAME = "report_result";
7
+ /** The pi extension command that carries one Ask's output schema to the Agent. */
8
+ export const REPORT_RESULT_COMMAND = "yaag-report-result";
9
+
10
+ /** Loadable source path for the private extension that owns `report_result`. */
11
+ export const REPORT_RESULT_EXTENSION_PATH = fileURLToPath(
12
+ // `fileURLToPath` decodes percent-escapes, such as a space in a parent path.
13
+ new URL("./report-result-extension.ts", import.meta.url),
14
+ );
15
+
16
+ /**
17
+ * The prompt message that delivers one Ask's schema, or deactivates the tool.
18
+ *
19
+ * pi routes a `/command` prompt to the extension and never to the model, so the
20
+ * Ask's own prompt bytes stay exactly what the Orchestration Program wrote.
21
+ */
22
+ export function reportResultCommandMessage(schema: CanonicalJsonObject | null): string {
23
+ return `/${REPORT_RESULT_COMMAND} ${JSON.stringify({ schema })}`;
24
+ }
25
+
26
+ /** True exactly for a prompt frame that carries the private schema command. */
27
+ export function isReportResultCommandFrame(frame: Frame): boolean {
28
+ return (
29
+ frame.type === "prompt" &&
30
+ typeof frame.message === "string" &&
31
+ frame.message.startsWith(`/${REPORT_RESULT_COMMAND} `)
32
+ );
33
+ }
34
+
35
+ /** One schema command, and the state change the Agent accepting it earns. */
36
+ export interface ReportResultCommand {
37
+ readonly message: string;
38
+ /** Records the new activation state, once the Agent has accepted the command. */
39
+ accepted(): void;
40
+ }
41
+
42
+ /**
43
+ * Tracks whether one Agent's `report_result` tool is active across its Asks.
44
+ *
45
+ * The tool is inactive at spawn, so an Agent that never declares an output
46
+ * schema exchanges exactly the frames it did before ADR-0032. A schema Ask
47
+ * always re-delivers its schema, because the schema is the tool's input schema.
48
+ */
49
+ export class ReportResultTool {
50
+ #active = false;
51
+
52
+ /** The command to send before this Ask's prompt, or null when none is needed. */
53
+ begin(schema: CanonicalJsonObject | undefined): ReportResultCommand | null {
54
+ if (schema === undefined && !this.#active) return null;
55
+ const active = schema !== undefined;
56
+ return {
57
+ message: reportResultCommandMessage(schema ?? null),
58
+ accepted: (): void => {
59
+ this.#active = active;
60
+ },
61
+ };
62
+ }
63
+ }
64
+
65
+ /** The value one accepted `report_result` call reported. */
66
+ export interface ReportedResult {
67
+ readonly value: unknown;
68
+ }
69
+
70
+ /**
71
+ * Collects the first accepted `report_result` call from one Ask's frames.
72
+ *
73
+ * The reported value is read from the tool result rather than from the call
74
+ * arguments, because pi coerces arguments before it runs the tool, and the
75
+ * coerced value is the one it validated. A refused call — invalid arguments,
76
+ * or a second call after the result was reported — carries an error and never
77
+ * becomes the Ask's result. Live settlement and Cassette playback read the
78
+ * same frames.
79
+ */
80
+ export class ReportResultCall {
81
+ #reported: ReportedResult | undefined;
82
+ #armed = false;
83
+
84
+ /**
85
+ * Accepts calls immediately, for folding one recorded Ask's own frames.
86
+ *
87
+ * A live Ask uses the ordinary constructor instead, which ignores everything
88
+ * until that Ask sends its prompt: a replayed prefix and an abandoned turn
89
+ * both deliver frames after the Ask they belong to ended, and such a call
90
+ * must never become the next Ask's result.
91
+ */
92
+ static armed(): ReportResultCall {
93
+ const call = new ReportResultCall();
94
+ call.arm();
95
+ return call;
96
+ }
97
+
98
+ /** Starts accepting calls, from the moment this Ask's prompt is sent. */
99
+ arm(): void {
100
+ this.#armed = true;
101
+ }
102
+
103
+ observe(frame: Frame): void {
104
+ if (!this.#armed || frame.type !== "tool_execution_end") return;
105
+ if (frame.toolName !== REPORT_RESULT_TOOL_NAME || frame.isError === true) return;
106
+ if (this.#reported !== undefined) return;
107
+ const result: unknown = frame.result;
108
+ if (!isObject(result) || result.isError === true) return;
109
+ const details: unknown = result.details;
110
+ if (!isObject(details) || !("reportedResult" in details)) return;
111
+ this.#reported = { value: details.reportedResult };
112
+ }
113
+
114
+ /** The accepted call, or undefined while the Ask has no reported result. */
115
+ get reported(): ReportedResult | undefined {
116
+ return this.#reported;
117
+ }
118
+ }
119
+
120
+ function isObject(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
122
+ }
@@ -1,12 +1,12 @@
1
1
  import type { Cassette, CassetteAgent } from "./cassette.ts";
2
2
  import { CassetteReplay } from "./cassette-replay.ts";
3
- import type { AskLimitOutcome, AskStalledOutcome } from "./errors.ts";
4
3
  import { FrameQueue } from "./frame-queue.ts";
5
4
  import { replayMismatch } from "./replay-divergence.ts";
6
5
  import { checkResumePreconditions } from "./resume-preconditions.ts";
7
6
  import type {
8
7
  AgentStats,
9
8
  AgentTransport,
9
+ AskCompletion,
10
10
  AskMarker,
11
11
  AskPlayback,
12
12
  Frame,
@@ -63,7 +63,7 @@ class ResumeTransport implements AgentTransport {
63
63
  this.#continuation = continuationOptions(agent, options);
64
64
  this.#replay = new CassetteReplay(agent);
65
65
  this.model = agent.model;
66
- void this.#forward(this.#replay.frames());
66
+ void this.#forwardReplay();
67
67
  }
68
68
 
69
69
  send(frame: Frame): void {
@@ -95,12 +95,12 @@ class ResumeTransport implements AgentTransport {
95
95
  return undefined;
96
96
  }
97
97
 
98
- finishAsk(
99
- outcome?: AskLimitOutcome,
100
- stalled?: AskStalledOutcome,
101
- invalidOutput?: import("./transport.ts").AskInvalidOutputPlayback,
102
- ): void {
103
- if (this.#mode === "live") this.#live?.finishAsk(outcome, stalled, invalidOutput);
98
+ finishAsk(completion: AskCompletion): void {
99
+ if (this.#mode === "live") this.#live?.finishAsk(completion);
100
+ }
101
+
102
+ recordedExtractionPolicy(index: number): string | undefined {
103
+ return this.#mode === "replay" ? this.#agent.asks[index]?.extractionPolicy : undefined;
104
104
  }
105
105
 
106
106
  close(): Promise<AgentStats> {
@@ -112,6 +112,9 @@ class ResumeTransport implements AgentTransport {
112
112
  // End the old source before attaching the new one, but keep the outward
113
113
  // queue open so Connection remains attached across the handoff.
114
114
  this.#replay.finish();
115
+ // Every replayed Ask is finished, so whatever the recorded prefix still has
116
+ // in flight belongs to none of the Asks that follow (ADR-0032).
117
+ this.#frames.discardPending();
115
118
  try {
116
119
  const live = await this.#liveFactory.open(this.#continuation);
117
120
  this.#live = live;
@@ -140,6 +143,21 @@ class ResumeTransport implements AgentTransport {
140
143
  async #forward(source: AsyncIterable<Frame>): Promise<void> {
141
144
  for await (const frame of source) this.#frames.push(frame);
142
145
  }
146
+
147
+ /**
148
+ * Forwards the recorded prefix, and stops the moment the Run goes live.
149
+ *
150
+ * The recorded frames of an Ask are released as a block, so the tail of the
151
+ * last replayed Ask can still be in flight when the Run goes live. Those
152
+ * frames belong to a finished Ask, and delivering them to the live Ask that
153
+ * follows would settle it, or answer it, with recorded work.
154
+ */
155
+ async #forwardReplay(): Promise<void> {
156
+ for await (const frame of this.#replay.frames()) {
157
+ if (this.#mode !== "replay") return;
158
+ this.#frames.push(frame);
159
+ }
160
+ }
143
161
  }
144
162
 
145
163
  function continuationOptions(agent: CassetteAgent, original: OpenOptions): OpenOptions {
@@ -1,76 +1,128 @@
1
- import { mkdir } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { mkdir, unlink } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
3
  import type { CassetteCollector, CassetteRun } from "./cassette.ts";
4
4
  import { publishCassette } from "./cassette-publish.ts";
5
- import { checkpointFileName } from "./checkpoint-dir.ts";
6
5
  import type { RunOutcome } from "./events.ts";
7
6
 
7
+ /** What a resume is about to re-execute; resolved once, at Run start. */
8
+ export type RunIdentity = Omit<CassetteRun, "outcome">;
9
+
8
10
  /**
9
11
  * One Run's publication request: what settled, what it collected, and where the
10
- * artifact may go. `record` wins over `checkpointDir`; a Run that neither
11
- * recorded nor stopped publishes nothing.
12
+ * artifact goes. The destination is fixed at Run start (ADR-0031): the
13
+ * `--record` path itself, or one name in the checkpoint directory.
12
14
  */
13
15
  export interface RunCheckpointOptions {
14
- /** Terminal outcome of the Run; only `stopped` publishes without `record`. */
16
+ /** Terminal outcome of the Run; only `stopped` keeps the artifact without `record`. */
15
17
  readonly outcome: RunOutcome;
16
18
  readonly collector: CassetteCollector;
17
- /** Explicit `--record` destination; overrides the default checkpoint directory. */
19
+ /** Explicit `--record` destination, or undefined when the Run only checkpoints. */
18
20
  readonly record: string | undefined;
19
- /** Already-resolved default directory a stopped Run publishes into. */
20
- readonly checkpointDir: string;
21
- /** Program entry point recorded as invocation identity; omitted when unknown. */
22
- readonly programFile: string | undefined;
23
- readonly args: unknown;
21
+ /** Where this Run's artifact lives, in-flight and at settlement. */
22
+ readonly destination: string;
23
+ /** Invocation identity written into the artifact's run block. */
24
+ readonly identity: RunIdentity;
24
25
  }
25
26
 
27
+ /**
28
+ * What one publication attempt did. `published` covers both a written artifact
29
+ * and a Run that keeps none. `displaced` carries the error that must become the
30
+ * Run's outcome. `lost` reports a Checkpoint the Run asked for and did not get,
31
+ * while a primary program error keeps the outcome.
32
+ */
33
+ export type RunCheckpointResult =
34
+ | { readonly kind: "published" }
35
+ | { readonly kind: "displaced"; readonly error: unknown }
36
+ | { readonly kind: "lost"; readonly message: string };
37
+
26
38
  /**
27
39
  * Publishes the collected Cassette when the Run must be restorable: always under
28
40
  * `--record`, and additionally into the checkpoint directory when the Run
29
- * stopped (ADR-0021/0024). Returns the error that must become the Run's outcome,
30
- * or null when there was nothing to publish or a primary program error owns it.
41
+ * stopped (ADR-0021/0024).
31
42
  */
32
- export async function publishRunCheckpoint(options: RunCheckpointOptions): Promise<unknown | null> {
33
- const destination = checkpointDestination(options);
34
- if (destination === null) return null;
43
+ export async function publishRunCheckpoint(
44
+ options: RunCheckpointOptions,
45
+ ): Promise<RunCheckpointResult> {
46
+ if (!keepsArtifact(options)) {
47
+ // A clean settlement without --record publishes nothing, so the in-flight
48
+ // artifact this Run flushed goes away with it (ADR-0031).
49
+ await discardCheckpoint(options.destination);
50
+ return { kind: "published" };
51
+ }
35
52
  try {
36
- if (options.record === undefined) {
37
- // Only the default directory is created; an explicit --record path keeps
38
- // today's rule that its directory must already exist.
39
- await mkdir(options.checkpointDir, { recursive: true, mode: 0o700 });
40
- }
41
- await publishCassette(destination, options.collector.cassette(await runBlock(options)));
42
- return null;
53
+ await ensureCheckpointDirectory(options.record, options.destination);
54
+ await publishCassette(
55
+ options.destination,
56
+ options.collector.cassette({ outcome: options.outcome, ...options.identity }),
57
+ );
58
+ return { kind: "published" };
43
59
  } catch (error) {
44
- // A secondary publication failure must not displace a primary program error.
60
+ // A secondary publication failure must not displace a primary program error,
61
+ // so the loss rides on run_end instead (ticket 04). The stderr diagnostic
62
+ // stays: it is the CLI user's own trace of the loss.
45
63
  if (options.outcome === "failed") {
46
64
  writeRecordingDiagnostic(error);
47
- return null;
65
+ return { kind: "lost", message: String(error) };
48
66
  }
49
67
  if (options.outcome === "stopped") {
50
- return new Error(`run stopped but is not restorable: ${String(error)}`, { cause: error });
68
+ return {
69
+ kind: "displaced",
70
+ error: new Error(`run stopped but is not restorable: ${String(error)}`, { cause: error }),
71
+ };
51
72
  }
52
73
  // A requested checkpoint silently going missing is worse than rejecting an
53
74
  // otherwise successful Run (ADR-0021).
54
- return error;
75
+ return { kind: "displaced", error };
55
76
  }
56
77
  }
57
78
 
58
- function checkpointDestination(options: RunCheckpointOptions): string | null {
59
- if (options.record !== undefined) return options.record;
60
- if (options.outcome !== "stopped") return null;
61
- return join(options.checkpointDir, checkpointFileName());
79
+ function keepsArtifact(options: RunCheckpointOptions): boolean {
80
+ return options.record !== undefined || options.outcome === "stopped";
81
+ }
82
+
83
+ /**
84
+ * Creates the checkpoint directory a Run owns. An explicit `--record` path keeps
85
+ * today's rule that its directory must already exist.
86
+ */
87
+ export async function ensureCheckpointDirectory(
88
+ record: string | undefined,
89
+ destination: string,
90
+ ): Promise<void> {
91
+ if (record !== undefined) return;
92
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
62
93
  }
63
94
 
64
- async function runBlock(options: RunCheckpointOptions): Promise<CassetteRun> {
65
- const { outcome, programFile } = options;
66
- if (programFile === undefined) return { outcome };
95
+ /** Resolves what a resume re-executes. The program hash is advisory only. */
96
+ export async function resolveRunIdentity(
97
+ programFile: string | undefined,
98
+ args: unknown,
99
+ ): Promise<RunIdentity> {
100
+ if (programFile === undefined) return {};
67
101
  const programHash = await hashProgram(programFile);
68
- return {
69
- outcome,
70
- programFile,
71
- args: options.args,
72
- ...(programHash === null ? {} : { programHash }),
73
- };
102
+ return { programFile, args, ...(programHash === null ? {} : { programHash }) };
103
+ }
104
+
105
+ /**
106
+ * Removes the in-progress artifact of a Run that publishes nothing. A missing
107
+ * file is the normal case: the Run may never have reached an Ask boundary. Any
108
+ * other failure is a diagnostic, because the file left behind states
109
+ * `interrupted` and a reader trusts that word (ADR-0031).
110
+ */
111
+ async function discardCheckpoint(destination: string): Promise<void> {
112
+ try {
113
+ await unlink(destination);
114
+ } catch (error) {
115
+ if (!isMissingFile(error)) writeRecordingDiagnostic(error);
116
+ }
117
+ }
118
+
119
+ function isMissingFile(error: unknown): boolean {
120
+ return (
121
+ typeof error === "object" &&
122
+ error !== null &&
123
+ "code" in error &&
124
+ (error as { readonly code?: unknown }).code === "ENOENT"
125
+ );
74
126
  }
75
127
 
76
128
  /** Advisory only: an unreadable program file omits the hash and never fails the Run. */