@tranhoangnguyen0310/pi-flow-external 2.1.0-external.3 → 2.2.0-external.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.md +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +28 -2
- package/docs/field-testing.md +13 -1
- package/package.json +1 -1
- package/src/core/agy.ts +2 -0
- package/src/core/claude.ts +2 -0
- package/src/core/codex.ts +2 -0
- package/src/core/progress.ts +5 -0
- package/src/core/run-inspection.ts +186 -7
- package/src/core/spawn.ts +14 -0
- package/src/external-command.ts +99 -8
- package/src/external-runs.ts +317 -14
- package/src/pi-subagent.ts +3 -0
- package/src/prompts.ts +1 -1
- package/src/types.ts +2 -0
- package/src/workflow/runtime.ts +5 -1
- package/src/workflow/tool.ts +40 -5
- package/src/workflow/types.ts +3 -1
package/AGENTS.md
CHANGED
|
@@ -36,7 +36,7 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
|
|
|
36
36
|
- Settings retention automatically prunes eligible completed records beyond `maxRunRecords`; active, interrupted, incomplete, and damaged records are not eligible.
|
|
37
37
|
- Structured nested-agent activity may extend the wall-clock deadline once, by one fresh base timeout, capped at twice the original deadline.
|
|
38
38
|
- Do not automatically retry failed or aborted external runs. Preserve the receipt and retry only when the user asks. Exception: the agy backend retries once on infrastructure-classified failures (auth, eligibility, network); the retry is disclosed in the receipt details (`retries`, `retryOf`) and never applies to agent-level failures, aborts, or timeouts.
|
|
39
|
-
- `external_runs` is session/project scoped. `list` pages runs and workflow roots; `inspect` pages
|
|
39
|
+
- `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget, and fails with an actionable error naming the run rather than ever silently dropping a target or serving a page over the caller's own byte budget. `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; `cancel` targets one stable ID and preserves its reason. An unresolvable `wf_...` ID fails as unknown/unavailable across `inspect`/`cancel`/`wait`/batch `inspect`, never falling through to the agent-only durable reader. No routine run event sends a parent message or notification.
|
|
40
40
|
|
|
41
41
|
## Workflow contract
|
|
42
42
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to pi-flow external are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [2.2.0-external.0] - 2026-09-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `external_runs` `inspect` now accepts a bounded `runIds` batch (up to 20, deduplicated, order preserved) returning one consistent normalized summary entry per target — live, durable, agent, or workflow — without waiting for completion.
|
|
12
|
+
- Added a verified `view: "final"` projection that returns only a backend's own canonical terminal answer, separate from combined narration/output; unavailable is a bounded, narration-free empty page (`finalAvailable: false`), never a synthesized sentence.
|
|
13
|
+
- Derived explicit queue/elapsed/activity timing (`queueDelayMs`, `elapsedMs`, `activityAgeMs`, `processDurationMs`) from existing evidence, computed only where the underlying timestamps genuinely exist; a registry-confirmed-live run is required before now-relative durations are computed, so an orphaned/crashed durable record is never misreported as actively running.
|
|
14
|
+
- `/external runs` list rows now show status, queue/elapsed duration, live activity age, and output/final availability, plus a manual `Refresh` choice that re-reads the current page and resets stale cursors.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Recorded an explicit `execution_started` evidence boundary (direct `Agent` calls and workflow `agent()` calls) so durable status classification and timing no longer depend solely on `processStartedAt`, which never exists for in-process Pi children.
|
|
19
|
+
- A workflow child's own registry entry now transitions `queued` → `running` explicitly at limiter-acquisition time, instead of only incidentally once its backend's first progress event fires.
|
|
20
|
+
- Settled `outcome.status` now wins over a possibly-stale ambient `observation.status` once a run has terminated.
|
|
21
|
+
- An unresolvable `wf_...` ID now rejects consistently as unknown/unavailable across `inspect`, `cancel`, `wait`, and batch `inspect`, instead of falling through to the agent-only durable reader.
|
|
22
|
+
|
|
7
23
|
## [2.1.0-external.3] - 2026-09-18
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -266,13 +266,39 @@ Agent({
|
|
|
266
266
|
|
|
267
267
|
Use `external_runs` with these actions:
|
|
268
268
|
|
|
269
|
-
- `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow.
|
|
270
|
-
- `inspect`: `runId
|
|
269
|
+
- `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow. Rows carry a `timing` projection (`queueDelayMs`, `elapsedMs`, `activityAgeMs` when live, `processDurationMs`) plus `outputAvailable`/`finalAvailable`.
|
|
270
|
+
- `inspect`: single `runId` with `view: "summary" | "output" | "diagnostics" | "final"`, and optional opaque `cursor`/`limitBytes` (max 64 KiB, same cap for every view). Follow `nextCursor` to avoid truncation. `summary` includes the same `timing` projection as `list`, plus `output.finalAvailable`. `final` returns only the verified canonical terminal answer — empty with `finalAvailable: false` until a successful terminal boundary exists; it never promotes partial/narration text. `output` stays the combined stream (assistant messages plus canonical result) and is unchanged.
|
|
271
|
+
- `inspect` with `runIds` instead of `runId` (up to 20, deduplicated, order preserved): a single bounded batch of `summary`-only projections — one cheap request to see whether several selected background children are queued, running, or terminal, each with `outputRef`/`diagnosticsRef` for follow-up detail. Ownership of every requested ID is validated before any page is returned. Reuses the same `limitBytes` cap as single-run inspection; pages contain whole target entries and continue through `nextCursor`, without invalidation from ordinary live progress. If one compact entry cannot fit, an actionable error asks you to increase `limitBytes` or inspect that run individually; no target is silently dropped. `runId` and `runIds` are mutually exclusive, and batch `view` must stay `summary`.
|
|
271
272
|
- `wait`: one `runId` or selected `runIds`, with `mode: "any" | "all"`. It returns terminal outcomes plus still-pending IDs; an unsuccessful workflow returns early even in `all` mode. It never chooses a winner or cancels pending work.
|
|
272
273
|
- `cancel`: one `runId` and optional reason. Whole-workflow cancellation stops active children; targeted child cancellation remains a catchable workflow outcome. Cancellation does not roll back edits or other side effects.
|
|
273
274
|
|
|
274
275
|
Interrupting a blocking `Agent`/`workflow` call cancels its work. Interrupting `external_runs wait` stops only that wait. Background work survives its launching tool return and ordinary parent turns, but not the owning session: orderly session shutdown requests cancellation and waits for bounded cleanup. This is not a daemon. After a host crash or unconfirmed shutdown, unfinished evidence is `interrupted_or_uncertain`; restart restores evidence access, never live ownership or guaranteed retrospective process termination. No routine activity wakes the parent, and live steering is not supported.
|
|
275
276
|
|
|
277
|
+
A parent's own `sleep`/wait duration (or its process lifetime) is not a measurement of, and does not bound, how long a delegated command actually takes to run — a background child keeps running under the session's ownership regardless of what the parent does next. A realistic collection shape:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
// Launch two independent background runs.
|
|
281
|
+
const one = Agent({ description: "Audit auth module", prompt: "Audit /absolute/repo/auth read-only.", role: "reviewer", background: true }); // -> run_...
|
|
282
|
+
const two = Agent({ description: "Audit billing module", prompt: "Audit /absolute/repo/billing read-only.", role: "reviewer", background: true }); // -> run_...
|
|
283
|
+
|
|
284
|
+
// Optional: give both a head start before doing anything else. This sleep
|
|
285
|
+
// bounds nothing about the children's own runtime — it is just the parent
|
|
286
|
+
// choosing when to next check in, not a deadline or a proxy for command time.
|
|
287
|
+
// sleep(30_000)
|
|
288
|
+
|
|
289
|
+
// Do unrelated parent work here (read files, answer the user, plan next steps)
|
|
290
|
+
// while both children continue running under the session, not the parent turn.
|
|
291
|
+
|
|
292
|
+
// Check on both at once, without waiting for either to finish:
|
|
293
|
+
// external_runs({ action: "inspect", runIds: [one.runId, two.runId] })
|
|
294
|
+
|
|
295
|
+
// Leave them running and come back later in the conversation (or after
|
|
296
|
+
// further parent work) to collect final output once each is actually done:
|
|
297
|
+
// external_runs({ action: "inspect", runId: one.runId, view: "final" })
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
A task that sounds like a 30-second command can legitimately take substantially longer end-to-end once queueing and backend overhead are included — inspect and wait, do not assume.
|
|
301
|
+
|
|
276
302
|
## Workflow usage
|
|
277
303
|
|
|
278
304
|
The `workflow` tool runs trusted JavaScript that calls one or more external roles and returns a JSON-serializable result. Workflows are for explicit fan-out — and for the case this extension was built to make explicit: running several children on the **same** harness at once. Example:
|
package/docs/field-testing.md
CHANGED
|
@@ -105,6 +105,17 @@ Local fixture tests own selection, boundary, and failure behavior; this check on
|
|
|
105
105
|
|
|
106
106
|
Do not run damaged-record and expected-failure provider scenarios manually. Their behavior is deterministic and belongs in the local fixture tests.
|
|
107
107
|
|
|
108
|
+
## Change-triggered background-run observability check
|
|
109
|
+
|
|
110
|
+
Run only when batch inspection, the `final` view, timing projection, or `/external runs` browsing changes. In one fresh Pi session against a read-only fixture:
|
|
111
|
+
|
|
112
|
+
1. Launch two independent `Agent({ ..., background: true })` calls back to back (do not wait on either), then do one unrelated piece of parent work (e.g. read a file) before checking on them — this exercises the actually-recommended usage shape (separate launches, other work in between), not a tight launch-then-immediately-inspect loop.
|
|
113
|
+
2. Call `external_runs({ action: "inspect", runIds: [<first>, <second>] })` once and confirm both come back as one bounded batch of `summary` projections (queued/running/terminal, `outputRef`/`diagnosticsRef` per entry) without waiting for either to finish.
|
|
114
|
+
3. After both children are confirmed terminal (`external_runs wait` or a later `inspect`), call `external_runs({ action: "inspect", runId: <runId>, view: "final" })` for a completed run and confirm it returns only the verified canonical answer; for a run cancelled mid-flight (or inspected before it settles), confirm the tool response reports `finalAvailable: false` with a deliberately empty, bounded projection (`items: []`, `integrity: "incomplete"`) rather than a fabricated success — this empty shape is correct at the tool layer, not a bug. The human-readable explanation ("No verified final answer is available yet for `<runId>`.") is a separate, UI-only notice that `/external runs` shows when Final is selected on an unsettled run; the tool response itself stays narration-free by design.
|
|
115
|
+
4. Run `/external runs`, open the list, and manually select `Refresh`: confirm it re-reads the first page and resets any list cursor without navigating into a run or waiting/polling. Select a still-running row and confirm its queue/elapsed timing and final-availability marker are present and update only when you refresh again (never on a timer).
|
|
116
|
+
|
|
117
|
+
Local fixture tests already cover batch pagination/cursor/byte-limit edge cases, the `final` view's success/unavailable/legitimate-null shapes, and `Refresh`'s cursor-reset behavior deterministically; this check only proves the real backend/registry timing (queued→running, activity age, terminal settlement) matches what those fixtures assume.
|
|
118
|
+
|
|
108
119
|
## Change-triggered project default-harness check
|
|
109
120
|
|
|
110
121
|
Run only when project default-harness resolution changes. In a fresh Pi session against a trusted read-only fixture containing `.pi/pi-flow-external/settings.json` with `"defaultHarness": "claude"`, verify `/external settings` reports `defaultHarness: claude (project: ...)`, delegate one read-only task with `role` only (no `harness`) and verify the receipt names a `claude-*` profile, then confirm an explicit `harness: "codex"` call still routes to codex. Repeat once in the same fixture with trust removed and confirm the override is ignored with a warning.
|
|
@@ -119,4 +130,5 @@ Before a runtime release:
|
|
|
119
130
|
4. Run interruption checks for adapters whose cancellation/output path changed.
|
|
120
131
|
5. Run the natural-language routing smoke only if role discovery, tool descriptions, or coordinator guidance changed.
|
|
121
132
|
6. Run the nested timeout check only if nested detection or timeout behavior changed.
|
|
122
|
-
7.
|
|
133
|
+
7. Run the background-run observability check only if batch inspection, the `final` view, timing projection, or `/external runs` browsing changed.
|
|
134
|
+
8. Remove temporary evidence and profiles.
|
package/package.json
CHANGED
package/src/core/agy.ts
CHANGED
|
@@ -266,6 +266,7 @@ export async function spawnAgySubagent(params: {
|
|
|
266
266
|
permission?: PermissionTier;
|
|
267
267
|
resumeConversationId?: string;
|
|
268
268
|
timeoutMs?: number;
|
|
269
|
+
executionStartedAt?: number;
|
|
269
270
|
}): Promise<AgentToolResult> {
|
|
270
271
|
const subagentType = params.profile.name;
|
|
271
272
|
const promptParts = [params.profile.systemPrompt, params.prompt, params.appendInstructions].filter(Boolean);
|
|
@@ -278,6 +279,7 @@ export async function spawnAgySubagent(params: {
|
|
|
278
279
|
backend: params.profile.backend,
|
|
279
280
|
enabled: params.progressEnabled,
|
|
280
281
|
onProgress: params.onProgress,
|
|
282
|
+
executionStartedAt: params.executionStartedAt,
|
|
281
283
|
});
|
|
282
284
|
const progress = emitter.progress;
|
|
283
285
|
const stderrBuffer = createBoundedBuffer(MAX_STDERR_CHARS);
|
package/src/core/claude.ts
CHANGED
|
@@ -340,6 +340,7 @@ export async function spawnClaudeSubagent(params: {
|
|
|
340
340
|
permission?: PermissionTier;
|
|
341
341
|
maxBudgetUsd?: number;
|
|
342
342
|
resumeSessionId?: string;
|
|
343
|
+
executionStartedAt?: number;
|
|
343
344
|
}): Promise<AgentToolResult> {
|
|
344
345
|
const subagentType = params.profile.name;
|
|
345
346
|
const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
|
|
@@ -350,6 +351,7 @@ export async function spawnClaudeSubagent(params: {
|
|
|
350
351
|
backend: params.profile.backend,
|
|
351
352
|
enabled: params.progressEnabled,
|
|
352
353
|
onProgress: params.onProgress,
|
|
354
|
+
executionStartedAt: params.executionStartedAt,
|
|
353
355
|
});
|
|
354
356
|
const progress = emitter.progress;
|
|
355
357
|
let latestRawUsage = emptyTokenUsage();
|
package/src/core/codex.ts
CHANGED
|
@@ -311,6 +311,7 @@ export async function spawnCodexSubagent(params: {
|
|
|
311
311
|
outputSchema?: unknown;
|
|
312
312
|
permission?: PermissionTier;
|
|
313
313
|
resumeSessionId?: string;
|
|
314
|
+
executionStartedAt?: number;
|
|
314
315
|
}): Promise<AgentToolResult> {
|
|
315
316
|
const subagentType = params.profile.name;
|
|
316
317
|
const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
|
|
@@ -321,6 +322,7 @@ export async function spawnCodexSubagent(params: {
|
|
|
321
322
|
backend: params.profile.backend,
|
|
322
323
|
enabled: params.progressEnabled,
|
|
323
324
|
onProgress: params.onProgress,
|
|
325
|
+
executionStartedAt: params.executionStartedAt,
|
|
324
326
|
});
|
|
325
327
|
const progress = emitter.progress;
|
|
326
328
|
let latestUsage = emptyUsage(params.profile.model);
|
package/src/core/progress.ts
CHANGED
|
@@ -186,6 +186,8 @@ export interface ProgressEmitterOptions {
|
|
|
186
186
|
backend?: SubagentBackend;
|
|
187
187
|
enabled: boolean;
|
|
188
188
|
onProgress: ((result: AgentToolResult) => void) | undefined;
|
|
189
|
+
/** Execution-start boundary captured by the caller right after the shared concurrency limiter granted a slot. */
|
|
190
|
+
executionStartedAt?: number;
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
export interface ProgressEmitter {
|
|
@@ -214,6 +216,9 @@ export interface ProgressEmitter {
|
|
|
214
216
|
export function createProgressEmitter(options: ProgressEmitterOptions): ProgressEmitter {
|
|
215
217
|
const { toolCallId, description, subagentType, backend, enabled, onProgress } = options;
|
|
216
218
|
const progress = createProgressNode(toolCallId, description, subagentType, "running", backend);
|
|
219
|
+
if (options.executionStartedAt !== undefined) {
|
|
220
|
+
progress.executionStartedAt = options.executionStartedAt;
|
|
221
|
+
}
|
|
217
222
|
const live = Boolean(enabled && onProgress);
|
|
218
223
|
|
|
219
224
|
let lastProgressEmit = 0;
|
|
@@ -14,7 +14,7 @@ const MAX_CURSOR_CHARS = 4096;
|
|
|
14
14
|
const MAX_LIST_SCAN = 200;
|
|
15
15
|
const RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{1,128}$/;
|
|
16
16
|
|
|
17
|
-
export type RunInspectionView = "output" | "diagnostics" | "summary";
|
|
17
|
+
export type RunInspectionView = "output" | "diagnostics" | "summary" | "final";
|
|
18
18
|
export type RunRecordIntegrity = "complete" | "incomplete" | "damaged";
|
|
19
19
|
export type RunOutputStatus = "preliminary" | "final" | "interrupted";
|
|
20
20
|
|
|
@@ -25,11 +25,41 @@ export interface RunInspectionPage {
|
|
|
25
25
|
view: RunInspectionView;
|
|
26
26
|
items: RunInspectionItem[];
|
|
27
27
|
outputStatus: RunOutputStatus;
|
|
28
|
+
/** Whether a separate verified final answer (view: "final") is available for this run. */
|
|
29
|
+
finalAvailable: boolean;
|
|
28
30
|
integrity: RunRecordIntegrity;
|
|
29
31
|
truncatedTail: boolean;
|
|
30
32
|
nextCursor?: string;
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Raw timestamp inputs for {@link deriveRunTiming}. Accepts either an ISO
|
|
37
|
+
* string (durable evidence) or an epoch-ms number (live `SubagentProgressNode`
|
|
38
|
+
* fields) so both `run-inspection.ts` and `external-runs.ts` can share one
|
|
39
|
+
* derivation without a normalization step at every call site.
|
|
40
|
+
*/
|
|
41
|
+
export interface RunTimingInputs {
|
|
42
|
+
queuedAt?: string | number;
|
|
43
|
+
executionStartedAt?: string | number;
|
|
44
|
+
processStartedAt?: string | number;
|
|
45
|
+
firstActivityAt?: string | number;
|
|
46
|
+
lastActivityAt?: string | number;
|
|
47
|
+
finishedAt?: string | number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RunTiming {
|
|
51
|
+
queuedAt?: string;
|
|
52
|
+
executionStartedAt?: string;
|
|
53
|
+
processStartedAt?: string;
|
|
54
|
+
firstActivityAt?: string;
|
|
55
|
+
lastActivityAt?: string;
|
|
56
|
+
finishedAt?: string;
|
|
57
|
+
queueDelayMs?: number;
|
|
58
|
+
elapsedMs?: number;
|
|
59
|
+
activityAgeMs?: number;
|
|
60
|
+
processDurationMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
export interface RunRecordListItem {
|
|
34
64
|
runId: string;
|
|
35
65
|
queuedAt?: string;
|
|
@@ -43,6 +73,8 @@ export interface RunRecordListItem {
|
|
|
43
73
|
parentSessionId?: string;
|
|
44
74
|
workflowRunId?: string;
|
|
45
75
|
outputAvailable: boolean;
|
|
76
|
+
finalAvailable: boolean;
|
|
77
|
+
timing: RunTiming;
|
|
46
78
|
integrity: RunRecordIntegrity;
|
|
47
79
|
}
|
|
48
80
|
|
|
@@ -61,6 +93,7 @@ interface ScanResult { missing: boolean; stopped: boolean; truncatedTail: boolea
|
|
|
61
93
|
interface RunObservation {
|
|
62
94
|
metadata?: Record<string, unknown>;
|
|
63
95
|
queuedAt?: string;
|
|
96
|
+
executionStartedAt?: string;
|
|
64
97
|
processStartedAt?: string;
|
|
65
98
|
firstActivityAt?: string;
|
|
66
99
|
lastActivityAt?: string;
|
|
@@ -94,6 +127,7 @@ export async function inspectRun({
|
|
|
94
127
|
const summary = await readSummary(join(directory, "summary.json"), runId);
|
|
95
128
|
const terminal = asRecord(summary.document?.summary);
|
|
96
129
|
const terminalStatus = asString(terminal?.status);
|
|
130
|
+
const finalAvailable = isFinalAvailable(terminal);
|
|
97
131
|
|
|
98
132
|
if (view === "summary") {
|
|
99
133
|
if (decoded?.kind === "inspect" && decoded.source !== "summary") throw new Error("Cursor source does not match summary view");
|
|
@@ -109,6 +143,7 @@ export async function inspectRun({
|
|
|
109
143
|
view,
|
|
110
144
|
items: portion.text ? [{ text: portion.text }] : [],
|
|
111
145
|
outputStatus: outputStatus(terminalStatus),
|
|
146
|
+
finalAvailable,
|
|
112
147
|
integrity: mergedIntegrity(summary, observation),
|
|
113
148
|
truncatedTail: observation.truncatedTail,
|
|
114
149
|
...(portion.nextOffset < text.length
|
|
@@ -118,6 +153,34 @@ export async function inspectRun({
|
|
|
118
153
|
}
|
|
119
154
|
|
|
120
155
|
if (decoded?.kind === "inspect" && decoded.source === "summary") throw new Error("Cursor source does not match inspection view");
|
|
156
|
+
|
|
157
|
+
if (view === "final") {
|
|
158
|
+
// Reuses the same canonical terminal result every other view already reads
|
|
159
|
+
// (canonicalResult/isFinalAvailable) — no separate backend parser or
|
|
160
|
+
// narration heuristic. Always a single small terminal-document item, so it
|
|
161
|
+
// reuses the same "terminal" cursor source as view: "output".
|
|
162
|
+
const observation = await readObservation(eventsPath, runId);
|
|
163
|
+
const finalText = finalAvailable ? canonicalResult(terminal) : undefined;
|
|
164
|
+
const items: RunInspectionItem[] = finalText ? [{ text: finalText }] : [];
|
|
165
|
+
const revision = contentRevision(finalText ?? "");
|
|
166
|
+
const finalCursor = decoded?.kind === "inspect"
|
|
167
|
+
? decoded
|
|
168
|
+
: { v: CURSOR_VERSION, kind: "inspect", runId, view, source: "terminal", itemIndex: 0, textOffset: 0, revision } as const;
|
|
169
|
+
if (finalCursor.source !== "terminal") throw new Error("Cursor source does not match final view");
|
|
170
|
+
if (finalCursor.revision !== revision) throw staleCursorError();
|
|
171
|
+
return terminalPage(
|
|
172
|
+
runId,
|
|
173
|
+
view,
|
|
174
|
+
items,
|
|
175
|
+
finalCursor,
|
|
176
|
+
limit,
|
|
177
|
+
mergedIntegrity(summary, observation),
|
|
178
|
+
observation.truncatedTail,
|
|
179
|
+
terminalStatus,
|
|
180
|
+
finalAvailable,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
121
184
|
if (view === "output" && summary.document && (terminalStatus === "done" || assistantItems(terminal).length > 0)) {
|
|
122
185
|
if (decoded?.kind === "inspect" && decoded.source === "events") throw staleCursorError();
|
|
123
186
|
const observation = await readObservation(eventsPath, runId);
|
|
@@ -137,6 +200,7 @@ export async function inspectRun({
|
|
|
137
200
|
mergedIntegrity(summary, observation),
|
|
138
201
|
observation.truncatedTail,
|
|
139
202
|
terminalStatus,
|
|
203
|
+
finalAvailable,
|
|
140
204
|
);
|
|
141
205
|
}
|
|
142
206
|
if (decoded?.kind === "inspect" && decoded.source === "terminal") throw new Error("Cursor source does not match diagnostics view");
|
|
@@ -187,6 +251,7 @@ export async function inspectRun({
|
|
|
187
251
|
view,
|
|
188
252
|
items,
|
|
189
253
|
outputStatus: outputStatus(terminalStatus),
|
|
254
|
+
finalAvailable,
|
|
190
255
|
integrity,
|
|
191
256
|
truncatedTail: scan.truncatedTail,
|
|
192
257
|
...(nextCursor ? { nextCursor: encodeCursor(nextCursor) } : {}),
|
|
@@ -258,8 +323,9 @@ async function readListItem(runsDirectory: string, runId: string): Promise<RunRe
|
|
|
258
323
|
const metadata = asRecord(summary.document?.metadata) ?? observation?.metadata;
|
|
259
324
|
if (!metadata && summary.integrity === "damaged") return undefined;
|
|
260
325
|
const terminal = asRecord(summary.document?.summary);
|
|
261
|
-
const
|
|
262
|
-
const
|
|
326
|
+
const executionStartedAt = timeValue(terminal?.executionStartedAt) ?? observation?.executionStartedAt;
|
|
327
|
+
const processStartedAtValue = timeValue(terminal?.processStartedAt) ?? observation?.processStartedAt;
|
|
328
|
+
const status = asString(terminal?.status) ?? classifyQueuedOrRunning(executionStartedAt, processStartedAtValue, observation?.outputAvailable);
|
|
263
329
|
const outcome = terminal
|
|
264
330
|
? status === "done" ? "succeeded" : terminal.timedOut === true ? "timed_out" : status === "aborted" ? "cancelled" : "failed"
|
|
265
331
|
: undefined;
|
|
@@ -278,10 +344,36 @@ async function readListItem(runsDirectory: string, runId: string): Promise<RunRe
|
|
|
278
344
|
...(asString(metadata?.parentSessionId) ? { parentSessionId: asString(metadata?.parentSessionId) } : {}),
|
|
279
345
|
...(asString(metadata?.workflowRunId) ? { workflowRunId: asString(metadata?.workflowRunId) } : {}),
|
|
280
346
|
outputAvailable: Boolean(canonicalResult(terminal) || assistantItems(terminal).length || observation?.outputAvailable),
|
|
347
|
+
finalAvailable: isFinalAvailable(terminal),
|
|
348
|
+
// Never live: a historical list row has no RunRegistry access either, so
|
|
349
|
+
// this timing is always the durable-evidence-only projection (live=false).
|
|
350
|
+
timing: deriveRunTiming({
|
|
351
|
+
queuedAt,
|
|
352
|
+
executionStartedAt,
|
|
353
|
+
processStartedAt: processStartedAtValue,
|
|
354
|
+
firstActivityAt: timeValue(terminal?.firstActivityAt) ?? observation?.firstActivityAt,
|
|
355
|
+
lastActivityAt: timeValue(terminal?.lastActivityAt) ?? observation?.lastActivityAt,
|
|
356
|
+
finishedAt: summary.document?.finishedAt,
|
|
357
|
+
}, false),
|
|
281
358
|
integrity: observation ? observation.integrity : summary.integrity,
|
|
282
359
|
};
|
|
283
360
|
}
|
|
284
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Shared queued/running classification for a raw (pre-integrity-override)
|
|
364
|
+
* status, used by both `readListItem` and `summaryProjection`.
|
|
365
|
+
* `executionStartedAt` is backend-agnostic (present for pi too, unlike
|
|
366
|
+
* `processStartedAt`, which only CLI backends with a child OS process ever
|
|
367
|
+
* emit) and is the earliest reliable "execution actually began" signal, so it
|
|
368
|
+
* must gate this classification alongside `processStartedAt`/output — or a pi
|
|
369
|
+
* run that has started but produced no output yet would misreport as still
|
|
370
|
+
* "queued". Does not affect the separate integrity/interrupted_or_uncertain
|
|
371
|
+
* distinction, applied independently downstream (e.g. by `historicalAgent`).
|
|
372
|
+
*/
|
|
373
|
+
function classifyQueuedOrRunning(executionStartedAt: unknown, processStartedAt: unknown, outputAvailable: unknown): "queued" | "running" {
|
|
374
|
+
return Boolean(executionStartedAt) || Boolean(processStartedAt) || Boolean(outputAvailable) ? "running" : "queued";
|
|
375
|
+
}
|
|
376
|
+
|
|
285
377
|
function documentTime(value: unknown): number | undefined {
|
|
286
378
|
if (typeof value !== "string") return undefined;
|
|
287
379
|
const time = Date.parse(value);
|
|
@@ -296,6 +388,8 @@ async function readObservation(eventsPath: string, runId: string): Promise<RunOb
|
|
|
296
388
|
if (event.type === "run_started") {
|
|
297
389
|
observation.metadata = asRecord(event.data);
|
|
298
390
|
observation.queuedAt = asString(observation.metadata?.queuedAt) ?? event.timestamp;
|
|
391
|
+
} else if (event.type === "execution_started") {
|
|
392
|
+
observation.executionStartedAt ??= event.timestamp;
|
|
299
393
|
} else if (event.type === "process_started") {
|
|
300
394
|
observation.processStartedAt ??= event.timestamp;
|
|
301
395
|
} else if (event.type === "backend_event") {
|
|
@@ -317,10 +411,12 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
|
|
|
317
411
|
const document = summary.document;
|
|
318
412
|
const metadata = asRecord(document?.metadata) ?? observation.metadata;
|
|
319
413
|
const terminal = asRecord(document?.summary);
|
|
414
|
+
const executionStartedAt = timeValue(terminal?.executionStartedAt) ?? observation.executionStartedAt;
|
|
320
415
|
const processStartedAt = timeValue(terminal?.processStartedAt) ?? observation.processStartedAt;
|
|
321
416
|
const firstActivityAt = timeValue(terminal?.firstActivityAt) ?? observation.firstActivityAt;
|
|
322
417
|
const lastActivityAt = timeValue(terminal?.lastActivityAt) ?? observation.lastActivityAt;
|
|
323
|
-
const status = asString(terminal?.status) ?? (processStartedAt
|
|
418
|
+
const status = asString(terminal?.status) ?? classifyQueuedOrRunning(executionStartedAt, processStartedAt, observation.outputAvailable);
|
|
419
|
+
const queuedAt = asString(document?.queuedAt) ?? observation.queuedAt;
|
|
324
420
|
return {
|
|
325
421
|
runId,
|
|
326
422
|
task: compactObject({
|
|
@@ -333,7 +429,7 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
|
|
|
333
429
|
}),
|
|
334
430
|
state: compactObject({
|
|
335
431
|
status,
|
|
336
|
-
queuedAt
|
|
432
|
+
queuedAt,
|
|
337
433
|
processStartedAt,
|
|
338
434
|
firstActivityAt,
|
|
339
435
|
lastActivityAt,
|
|
@@ -341,9 +437,22 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
|
|
|
341
437
|
error: boundedString(terminal?.error),
|
|
342
438
|
integrity: mergedIntegrity(summary, observation),
|
|
343
439
|
}),
|
|
440
|
+
// Never live here: this projection is durable-evidence-only (inspectRun has
|
|
441
|
+
// no RunRegistry access), so now-relative fields (elapsedMs while running,
|
|
442
|
+
// activityAgeMs) are never computed — only src/external-runs.ts may do that,
|
|
443
|
+
// and only for a target it resolves through registry.get(runId).
|
|
444
|
+
timing: deriveRunTiming({
|
|
445
|
+
queuedAt,
|
|
446
|
+
executionStartedAt,
|
|
447
|
+
processStartedAt,
|
|
448
|
+
firstActivityAt,
|
|
449
|
+
lastActivityAt,
|
|
450
|
+
finishedAt: document?.finishedAt,
|
|
451
|
+
}, false),
|
|
344
452
|
output: {
|
|
345
453
|
available: Boolean(canonicalResult(terminal) || assistantItems(terminal).length || observation.outputAvailable),
|
|
346
454
|
status: outputStatus(asString(terminal?.status)),
|
|
455
|
+
finalAvailable: isFinalAvailable(terminal),
|
|
347
456
|
},
|
|
348
457
|
};
|
|
349
458
|
}
|
|
@@ -457,7 +566,7 @@ async function validateEventCursor(path: string, runId: string, view: RunInspect
|
|
|
457
566
|
}
|
|
458
567
|
}
|
|
459
568
|
|
|
460
|
-
function terminalPage(runId: string, view: "output", items: RunInspectionItem[], cursor: Extract<InspectionCursor, { source: "terminal" }>, limit: number, integrity: RunRecordIntegrity, truncatedTail: boolean, terminalStatus: string | undefined): RunInspectionPage {
|
|
569
|
+
function terminalPage(runId: string, view: "output" | "final", items: RunInspectionItem[], cursor: Extract<InspectionCursor, { source: "terminal" }>, limit: number, integrity: RunRecordIntegrity, truncatedTail: boolean, terminalStatus: string | undefined, finalAvailable: boolean): RunInspectionPage {
|
|
461
570
|
if (items.length || cursor.itemIndex !== 0 || cursor.textOffset !== 0) validateItemCursor(items, cursor.itemIndex, cursor.textOffset);
|
|
462
571
|
const served = serveItems(items, cursor.itemIndex, cursor.textOffset, limit);
|
|
463
572
|
return {
|
|
@@ -465,6 +574,7 @@ function terminalPage(runId: string, view: "output", items: RunInspectionItem[],
|
|
|
465
574
|
view,
|
|
466
575
|
items: served.items,
|
|
467
576
|
outputStatus: outputStatus(terminalStatus),
|
|
577
|
+
finalAvailable,
|
|
468
578
|
integrity,
|
|
469
579
|
truncatedTail,
|
|
470
580
|
...(served.next ? { nextCursor: encodeCursor({ v: CURSOR_VERSION, kind: "inspect", runId, view, source: "terminal", ...served.next, revision: cursor.revision }) } : {}),
|
|
@@ -538,6 +648,75 @@ function canonicalResult(summary: Record<string, unknown> | undefined): string |
|
|
|
538
648
|
return summary?.status === "done" ? textValue(summary.structuredOutput ?? summary.result) : undefined;
|
|
539
649
|
}
|
|
540
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Whether a separate verified final answer (view: "final") is available.
|
|
653
|
+
* Deliberately identical to `Boolean(canonicalResult(summary))`: the terminal
|
|
654
|
+
* `status: "done"` gate lives inside `canonicalResult` itself, so there is
|
|
655
|
+
* exactly one place that decides "this run has a verified successful
|
|
656
|
+
* terminal boundary" — no second heuristic, no narration promotion.
|
|
657
|
+
*/
|
|
658
|
+
function isFinalAvailable(summary: Record<string, unknown> | undefined): boolean {
|
|
659
|
+
return Boolean(canonicalResult(summary));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function msValue(value: string | number | undefined): number | undefined {
|
|
663
|
+
if (value === undefined) return undefined;
|
|
664
|
+
const parsed = typeof value === "number" ? value : Date.parse(value);
|
|
665
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function nonNegativeDelta(end: number | undefined, start: number | undefined): number | undefined {
|
|
669
|
+
if (end === undefined || start === undefined) return undefined;
|
|
670
|
+
const delta = end - start;
|
|
671
|
+
return delta >= 0 ? delta : undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function isoValue(value: string | number | undefined): string | undefined {
|
|
675
|
+
if (value === undefined) return undefined;
|
|
676
|
+
return typeof value === "string" ? value : timeValue(value);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* One shared, pure timing projection used by durable summaries, list entries,
|
|
681
|
+
* batch inspection, and (via `live: true`) live registry-backed summaries.
|
|
682
|
+
*
|
|
683
|
+
* `live` must be true ONLY for a target independently confirmed still running
|
|
684
|
+
* by `RunRegistry` (i.e. resolved via `registry.get(runId)` in
|
|
685
|
+
* `src/external-runs.ts`). Durable-evidence-only callers in this module
|
|
686
|
+
* (`inspectRun`, `listRunRecords`, `getRunRecord`) always pass `false`: a
|
|
687
|
+
* record with no `finishedAt` and no owning registry entry could be a
|
|
688
|
+
* genuinely still-running child, or an orphaned/crashed one whose parent
|
|
689
|
+
* session never got to write `finishedAt` — durable evidence alone cannot
|
|
690
|
+
* tell the two apart, so a `now`-relative field is never fabricated for it.
|
|
691
|
+
*/
|
|
692
|
+
export function deriveRunTiming(inputs: RunTimingInputs, live: boolean, now: number = Date.now()): RunTiming {
|
|
693
|
+
const queuedAtMs = msValue(inputs.queuedAt);
|
|
694
|
+
const executionStartedAtMs = msValue(inputs.executionStartedAt);
|
|
695
|
+
const processStartedAtMs = msValue(inputs.processStartedAt);
|
|
696
|
+
const lastActivityAtMs = msValue(inputs.lastActivityAt);
|
|
697
|
+
const finishedAtMs = msValue(inputs.finishedAt);
|
|
698
|
+
|
|
699
|
+
const queueDelayMs = nonNegativeDelta(executionStartedAtMs, queuedAtMs);
|
|
700
|
+
const elapsedMs = finishedAtMs !== undefined
|
|
701
|
+
? nonNegativeDelta(finishedAtMs, executionStartedAtMs)
|
|
702
|
+
: live ? nonNegativeDelta(now, executionStartedAtMs) : undefined;
|
|
703
|
+
const activityAgeMs = live && finishedAtMs === undefined ? nonNegativeDelta(now, lastActivityAtMs) : undefined;
|
|
704
|
+
const processDurationMs = nonNegativeDelta(finishedAtMs, processStartedAtMs);
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
...(isoValue(inputs.queuedAt) ? { queuedAt: isoValue(inputs.queuedAt) } : {}),
|
|
708
|
+
...(isoValue(inputs.executionStartedAt) ? { executionStartedAt: isoValue(inputs.executionStartedAt) } : {}),
|
|
709
|
+
...(isoValue(inputs.processStartedAt) ? { processStartedAt: isoValue(inputs.processStartedAt) } : {}),
|
|
710
|
+
...(isoValue(inputs.firstActivityAt) ? { firstActivityAt: isoValue(inputs.firstActivityAt) } : {}),
|
|
711
|
+
...(isoValue(inputs.lastActivityAt) ? { lastActivityAt: isoValue(inputs.lastActivityAt) } : {}),
|
|
712
|
+
...(isoValue(inputs.finishedAt) ? { finishedAt: isoValue(inputs.finishedAt) } : {}),
|
|
713
|
+
...(queueDelayMs !== undefined ? { queueDelayMs } : {}),
|
|
714
|
+
...(elapsedMs !== undefined ? { elapsedMs } : {}),
|
|
715
|
+
...(activityAgeMs !== undefined ? { activityAgeMs } : {}),
|
|
716
|
+
...(processDurationMs !== undefined ? { processDurationMs } : {}),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
541
720
|
function textValue(value: unknown): string | undefined {
|
|
542
721
|
if (typeof value === "string") return value || undefined;
|
|
543
722
|
if (value === undefined || value === null) return undefined;
|
|
@@ -623,7 +802,7 @@ function hasKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
|
623
802
|
}
|
|
624
803
|
|
|
625
804
|
function isView(value: unknown): value is RunInspectionView {
|
|
626
|
-
return value === "output" || value === "diagnostics" || value === "summary";
|
|
805
|
+
return value === "output" || value === "diagnostics" || value === "summary" || value === "final";
|
|
627
806
|
}
|
|
628
807
|
|
|
629
808
|
function isNonNegativeInteger(value: unknown): value is number {
|
package/src/core/spawn.ts
CHANGED
|
@@ -90,6 +90,14 @@ export interface SpawnSubagentParams {
|
|
|
90
90
|
resumeRunId?: string;
|
|
91
91
|
/** Evidence identity allocated by the caller before waiting for a concurrency slot. */
|
|
92
92
|
runRecord?: RunRecord;
|
|
93
|
+
/**
|
|
94
|
+
* Execution-start boundary (epoch ms), captured by the caller immediately after
|
|
95
|
+
* the shared concurrency limiter granted this run a slot. Callers always acquire
|
|
96
|
+
* the limiter themselves before calling spawnSubagent (see the class doc comment
|
|
97
|
+
* above); this field only carries that already-captured timestamp through so it
|
|
98
|
+
* reaches the terminal summary and the live progress snapshot.
|
|
99
|
+
*/
|
|
100
|
+
executionStartedAt?: number;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
interface SpawnSubagentRuntimeParams extends SpawnSubagentParams {
|
|
@@ -372,6 +380,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
|
|
|
372
380
|
timedOut: details.timedOut === true,
|
|
373
381
|
result: details.result,
|
|
374
382
|
assistantOutput: details.assistantOutput,
|
|
383
|
+
executionStartedAt: details.progress?.executionStartedAt,
|
|
375
384
|
processStartedAt: details.progress?.processStartedAt,
|
|
376
385
|
firstActivityAt: details.progress?.firstActivityAt,
|
|
377
386
|
lastActivityAt: details.progress?.lastActivityAt,
|
|
@@ -425,6 +434,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
|
|
|
425
434
|
description: params.description,
|
|
426
435
|
status: "error",
|
|
427
436
|
error: error instanceof Error ? error.message : String(error),
|
|
437
|
+
executionStartedAt: params.executionStartedAt,
|
|
428
438
|
durationMs: Date.now() - startedAt,
|
|
429
439
|
backendEventCount,
|
|
430
440
|
nestedActivitySeen,
|
|
@@ -456,6 +466,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
|
|
|
456
466
|
outputSchema: params.outputSchema,
|
|
457
467
|
permission: params.permission,
|
|
458
468
|
resumeSessionId: params.resumeSessionId,
|
|
469
|
+
executionStartedAt: params.executionStartedAt,
|
|
459
470
|
});
|
|
460
471
|
}
|
|
461
472
|
if (params.profile.backend === "agy") {
|
|
@@ -477,6 +488,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
|
|
|
477
488
|
outputSchema: params.outputSchema,
|
|
478
489
|
permission: params.permission,
|
|
479
490
|
resumeConversationId: params.resumeSessionId,
|
|
491
|
+
executionStartedAt: params.executionStartedAt,
|
|
480
492
|
// ponytail: 2x base because extendOnce() may legitimately double the
|
|
481
493
|
// outer deadline; the outer AbortSignal remains the real authority.
|
|
482
494
|
timeoutMs: params.timeoutMs * 2,
|
|
@@ -519,6 +531,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
|
|
|
519
531
|
permission: params.permission,
|
|
520
532
|
maxBudgetUsd: params.maxBudgetUsd,
|
|
521
533
|
resumeSessionId: params.resumeSessionId,
|
|
534
|
+
executionStartedAt: params.executionStartedAt,
|
|
522
535
|
});
|
|
523
536
|
}
|
|
524
537
|
if (!params.model) {
|
|
@@ -616,6 +629,7 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
|
|
|
616
629
|
backend: profile.backend,
|
|
617
630
|
enabled: progressEnabled,
|
|
618
631
|
onProgress,
|
|
632
|
+
executionStartedAt: params.executionStartedAt,
|
|
619
633
|
});
|
|
620
634
|
const progress = emitter.progress;
|
|
621
635
|
|