pi-crew 0.11.4 → 0.11.6
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/CHANGELOG.md +109 -0
- package/README.md +2 -2
- package/dist/index.mjs +498 -492
- package/docs/actions-reference.md +22 -0
- package/docs/dynamic-workflows.md +37 -0
- package/index.ts +9 -5
- package/package.json +18 -14
- package/scripts/analyze-run.mjs +101 -14
- package/skills/model-routing-context/SKILL.md +4 -3
- package/skills/real-test-pi-crew/SKILL.md +10 -7
- package/src/config/env-vars.ts +6 -0
- package/src/extension/crew-vibes/index.ts +29 -10
- package/src/extension/pi-api.ts +1 -0
- package/src/extension/registration/lifecycle-handlers.ts +65 -3
- package/src/extension/team-tool/dispatch/index.ts +4 -3
- package/src/extension/team-tool/dispatch/status.ts +7 -3
- package/src/extension/team-tool/inspect.ts +90 -0
- package/src/extension/team-tool/respond.ts +54 -0
- package/src/extension/team-tool/status.ts +10 -0
- package/src/extension/team-tool.ts +393 -3
- package/src/extension/webhook-notify.ts +23 -1
- package/src/observability/exporters/otlp-exporter.ts +93 -11
- package/src/prompt/prompt-runtime.ts +13 -4
- package/src/runtime/agent-control.ts +1 -15
- package/src/runtime/background-runner.ts +3 -0
- package/src/runtime/broker/crew-broker.ts +12 -103
- package/src/runtime/broker/protocol/request-parsers.ts +9 -1
- package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
- package/src/runtime/dispatch-batch.ts +3 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
- package/src/runtime/live-session/live-agent-manager.ts +35 -0
- package/src/runtime/live-session/live-session-runtime.ts +34 -5
- package/src/runtime/mcp-proxy.ts +110 -7
- package/src/runtime/model/pi-args.ts +14 -11
- package/src/runtime/model/provider-extensions.ts +67 -1
- package/src/runtime/recovery/crash-recovery.ts +6 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
- package/src/runtime/stale-reconciler.ts +55 -0
- package/src/runtime/task-output-context.ts +11 -1
- package/src/runtime/task-runner/child-executor.ts +130 -4
- package/src/runtime/task-runner/output-splitter.ts +48 -5
- package/src/runtime/task-runner/post-execution.ts +2 -2
- package/src/runtime/task-runner/pre-execution.ts +37 -24
- package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
- package/src/schema/team-tool-schema.ts +27 -1
- package/src/state/contracts.ts +16 -0
- package/src/state/crew-init.ts +114 -0
- package/src/state/stores/active-run-registry.ts +13 -6
- package/src/state/types.ts +0 -8
- package/src/ui/goal-flag.ts +44 -0
- package/src/ui/live-run-sidebar.ts +5 -1
- package/src/ui/pi-ui-compat.ts +20 -3
- package/src/ui/run-dashboard.ts +2 -1
- package/src/ui/widget/widget-model.ts +6 -2
- package/src/utils/ip-literal.ts +145 -0
- package/src/workflows/workflow-config.ts +6 -0
|
@@ -34,6 +34,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
34
34
|
let providerTimer: ReturnType<typeof setInterval> | undefined;
|
|
35
35
|
let lastProviderUsage: ProviderUsage | null = null;
|
|
36
36
|
let currentProvider: string | undefined;
|
|
37
|
+
// Latest ctx of the CURRENT session, re-fetched at every publish/tick.
|
|
38
|
+
// Never hold a ctx captured from an earlier event across a session
|
|
39
|
+
// replacement (newSession/fork/switchSession/reload): pi invalidates every
|
|
40
|
+
// prior ctx, and even reading ctx.hasUI then throws the stale-ctx error.
|
|
41
|
+
// session_shutdown clears this so in-flight fetch continuations no-op
|
|
42
|
+
// instead of publishing into a dead session (WI-1, SDD-4).
|
|
43
|
+
let sessionCtx: ExtensionContext | undefined;
|
|
37
44
|
|
|
38
45
|
function themeOf(ctx: ExtensionContext) {
|
|
39
46
|
return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
|
|
@@ -57,29 +64,37 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
57
64
|
/** Fetch provider usage for currentProvider and publish the quota status.
|
|
58
65
|
* Called on start, on each timer tick, and immediately when the
|
|
59
66
|
* provider changes (model_select) so the quota reflects the new
|
|
60
|
-
* provider without waiting for the next tick.
|
|
61
|
-
|
|
67
|
+
* provider without waiting for the next tick. The ctx is resolved at
|
|
68
|
+
* call time (never captured) and re-checked after the await: if the
|
|
69
|
+
* session was replaced while the fetch was in flight, the continuation
|
|
70
|
+
* is dropped — the new session re-arms its own publish. */
|
|
71
|
+
async function fetchProviderAndRefresh(): Promise<void> {
|
|
72
|
+
const ctx = sessionCtx;
|
|
73
|
+
if (!ctx) return; // session already shut down or replaced — nothing to publish to
|
|
62
74
|
if (!config.enabled || !config.capacity.providerUsage) {
|
|
63
75
|
lastProviderUsage = null;
|
|
64
76
|
publishQuotaStatus(ctx);
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
79
|
+
let usage: ProviderUsage | null = null;
|
|
67
80
|
try {
|
|
68
|
-
|
|
81
|
+
usage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
|
|
69
82
|
} catch {
|
|
70
83
|
// Never crash on provider fetch failure
|
|
71
|
-
|
|
84
|
+
usage = null;
|
|
72
85
|
}
|
|
86
|
+
if (sessionCtx !== ctx) return; // replaced mid-fetch: old ctx must never be used
|
|
87
|
+
lastProviderUsage = usage;
|
|
73
88
|
publishQuotaStatus(ctx);
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
function startProviderTimer(
|
|
91
|
+
function startProviderTimer(): void {
|
|
77
92
|
if (providerTimer) return;
|
|
78
93
|
if (!config.capacity.providerUsage) return;
|
|
79
94
|
const interval = Math.max(10000, config.capacity.providerRefreshMs);
|
|
80
95
|
|
|
81
|
-
fetchProviderAndRefresh(
|
|
82
|
-
providerTimer = setInterval(() => fetchProviderAndRefresh(
|
|
96
|
+
fetchProviderAndRefresh(); // Fetch immediately on start
|
|
97
|
+
providerTimer = setInterval(() => fetchProviderAndRefresh(), interval);
|
|
83
98
|
providerTimer.unref?.();
|
|
84
99
|
}
|
|
85
100
|
|
|
@@ -90,12 +105,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
90
105
|
clearVibesStatus(ctx);
|
|
91
106
|
return;
|
|
92
107
|
}
|
|
93
|
-
if (config.capacity.providerUsage) startProviderTimer(
|
|
108
|
+
if (config.capacity.providerUsage) startProviderTimer();
|
|
94
109
|
else publishQuotaStatus(ctx); // clears the stale status when disabled
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
pi.on("session_start", (_event, ctx) => {
|
|
98
113
|
stopProviderTimer();
|
|
114
|
+
sessionCtx = ctx; // fresh ctx of the current session — any prior ctx is invalid now
|
|
99
115
|
config = loadConfig();
|
|
100
116
|
clearProviderUsageCache();
|
|
101
117
|
// Initialize provider from current model — model_select only fires on manual switch
|
|
@@ -104,19 +120,22 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
104
120
|
clearVibesStatus(ctx);
|
|
105
121
|
return;
|
|
106
122
|
}
|
|
107
|
-
startProviderTimer(
|
|
123
|
+
startProviderTimer();
|
|
108
124
|
});
|
|
109
125
|
|
|
110
126
|
pi.on("model_select", (event, ctx) => {
|
|
127
|
+
sessionCtx = ctx;
|
|
111
128
|
currentProvider = (event as { model?: { provider?: string } }).model?.provider;
|
|
112
129
|
clearProviderUsageCache();
|
|
113
130
|
// Fetch immediately so the quota reflects the new provider without
|
|
114
131
|
// waiting for the next timer tick.
|
|
115
|
-
fetchProviderAndRefresh(
|
|
132
|
+
fetchProviderAndRefresh();
|
|
116
133
|
});
|
|
117
134
|
|
|
118
135
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
119
136
|
stopProviderTimer();
|
|
137
|
+
sessionCtx = undefined; // later continuations must no-op, never touch a disposed ctx
|
|
138
|
+
// ctx is still valid here: pi emits session_shutdown before dispose().
|
|
120
139
|
clearVibesStatus(ctx);
|
|
121
140
|
});
|
|
122
141
|
|
package/src/extension/pi-api.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/sched
|
|
|
42
42
|
import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
|
|
43
43
|
import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
|
|
44
44
|
import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
|
|
45
|
+
import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
|
|
45
46
|
import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
|
|
46
47
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
47
48
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
@@ -88,6 +89,62 @@ export function installSessionLifecycleHandlers(pi: ExtensionAPI, ctx: Registrat
|
|
|
88
89
|
installModelTrackingHandlers(pi);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// ─── NEW-2 (SDD-4 follow-up, P3 review MAJOR 1): honest stale-reconcile notify ──
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Repaired runIds already surfaced by the session-start stale-reconcile
|
|
96
|
+
* notify in THIS process, bounded so a long-lived session cannot grow the set
|
|
97
|
+
* unboundedly (overflow clears — a re-notify after 256+ distinct repairs is
|
|
98
|
+
* acceptable noise, an unbounded leak is not).
|
|
99
|
+
*
|
|
100
|
+
* Cross-restart dedupe is structural, not tracked here: a real repair persists
|
|
101
|
+
* a terminal run status, so the run drops out of the reconcile input and can
|
|
102
|
+
* never re-notify. The set guards the two in-process repeat windows — session
|
|
103
|
+
* reload/fork re-firing session_start, and a repair whose persistence failed
|
|
104
|
+
* (disk error / lock steal leaves the run reconcileable while the notify still
|
|
105
|
+
* claimed it). Observed pre-fix (2026-09-29): 77 dishonest notifies/day,
|
|
106
|
+
* ~3x per runId, because non-repaired verdicts (blocked_awaiting_approval,
|
|
107
|
+
* waiting_answer, result_exists) never persist anything and re-fired the
|
|
108
|
+
* "Found and repaired ghost runs" text on every session start.
|
|
109
|
+
*/
|
|
110
|
+
const notifiedRepairedRunIds = new Set<string>();
|
|
111
|
+
const NOTIFIED_REPAIRED_RUN_IDS_CAP = 256;
|
|
112
|
+
|
|
113
|
+
/** What the session-start stale-reconcile notify should say (or null: say nothing). */
|
|
114
|
+
export interface StaleReconcileNotifyPlan {
|
|
115
|
+
title: string;
|
|
116
|
+
body: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* NEW-2: decide the operator notification for a session-start reconcile
|
|
121
|
+
* batch. Honest by construction — a notify is emitted ONLY when reconcile
|
|
122
|
+
* actually repaired something (`repaired === true`): non-repaired verdicts
|
|
123
|
+
* are intentional states (plan approval pending, ask parked, result already
|
|
124
|
+
* exists) that the run dashboard already surfaces, so claiming "repaired"
|
|
125
|
+
* for them was a false system statement. The title says exactly what
|
|
126
|
+
* happened ("Repaired N stale run(s)"), the body names each repaired runId
|
|
127
|
+
* with its verdict, and each repaired runId notifies at most once per
|
|
128
|
+
* process (bounded set above).
|
|
129
|
+
*/
|
|
130
|
+
export function decideStaleReconcileNotification(staleResults: ReconcileResult[]): StaleReconcileNotifyPlan | null {
|
|
131
|
+
const repaired = staleResults.filter((r) => r.repaired === true && !notifiedRepairedRunIds.has(r.runId));
|
|
132
|
+
if (repaired.length === 0) return null;
|
|
133
|
+
for (const r of repaired) {
|
|
134
|
+
if (notifiedRepairedRunIds.size >= NOTIFIED_REPAIRED_RUN_IDS_CAP) notifiedRepairedRunIds.clear();
|
|
135
|
+
notifiedRepairedRunIds.add(r.runId);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
title: `Repaired ${repaired.length} stale run(s)`,
|
|
139
|
+
body: `Repaired stale runs from previous sessions: ${repaired.map((r) => `${r.runId} (${r.verdict})`).join(", ")}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Test seam: reset the per-process dedupe set between unit tests. */
|
|
144
|
+
export function __test__resetNotifiedRepairedRunIds(): void {
|
|
145
|
+
notifiedRepairedRunIds.clear();
|
|
146
|
+
}
|
|
147
|
+
|
|
91
148
|
/**
|
|
92
149
|
* Hand over the outcome of runs detached by an agent-view switch.
|
|
93
150
|
*
|
|
@@ -460,13 +517,18 @@ async function runDeferredSessionCleanup(
|
|
|
460
517
|
// session_start callback synchronous; notify when the result lands.
|
|
461
518
|
void reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId)
|
|
462
519
|
.then((staleResults) => {
|
|
463
|
-
|
|
520
|
+
// NEW-2 (SDD-4 follow-up): honest notify — only actual repairs are
|
|
521
|
+
// reported, each repaired runId at most once per process. Non-repaired
|
|
522
|
+
// verdicts (blocked_awaiting_approval / waiting_answer /
|
|
523
|
+
// result_exists) must NEVER be claimed as "repaired".
|
|
524
|
+
const plan = decideStaleReconcileNotification(staleResults ?? []);
|
|
525
|
+
if (plan) {
|
|
464
526
|
ctx.notifyOperator({
|
|
465
527
|
id: "stale_reconcile",
|
|
466
528
|
severity: "info",
|
|
467
529
|
source: "crash-recovery",
|
|
468
|
-
title:
|
|
469
|
-
body:
|
|
530
|
+
title: plan.title,
|
|
531
|
+
body: plan.body,
|
|
470
532
|
});
|
|
471
533
|
}
|
|
472
534
|
})
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 54→5 mapping:
|
|
9
9
|
* run (9): run, parallel, plan, orchestrate, resume, retry, wait, steer, goal
|
|
10
|
-
* status (
|
|
11
|
-
* health, worktrees, checkpoint, cache, explain, onboard, recommend, help
|
|
10
|
+
* status (17): status, list, get, events, artifacts, breakdown, summary, graph,
|
|
11
|
+
* search, health, worktrees, checkpoint, cache, explain, onboard, recommend, help
|
|
12
12
|
* control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
|
|
13
13
|
* manage (17): create, update, delete, init, config, validate, autonomy, settings,
|
|
14
14
|
* workflow-create/get/list/save/delete, import, imports, export, compare
|
|
@@ -36,12 +36,13 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
|
|
|
36
36
|
steer: "run",
|
|
37
37
|
goal: "run",
|
|
38
38
|
|
|
39
|
-
// status domain (
|
|
39
|
+
// status domain (17)
|
|
40
40
|
status: "status",
|
|
41
41
|
list: "status",
|
|
42
42
|
get: "status",
|
|
43
43
|
events: "status",
|
|
44
44
|
artifacts: "status",
|
|
45
|
+
breakdown: "status",
|
|
45
46
|
summary: "status",
|
|
46
47
|
graph: "status",
|
|
47
48
|
search: "status",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API-5 facade dispatch — Status domain router.
|
|
3
3
|
*
|
|
4
|
-
* Actions: status, list, get, events, artifacts, summary, graph,
|
|
5
|
-
* health, worktrees, checkpoint, cache, explain, onboard, recommend,
|
|
4
|
+
* Actions: status, list, get, events, artifacts, breakdown, summary, graph,
|
|
5
|
+
* search, health, worktrees, checkpoint, cache, explain, onboard, recommend,
|
|
6
|
+
* help.
|
|
6
7
|
*
|
|
7
8
|
* Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
|
|
8
9
|
* help) are moved verbatim from the former handleTeamTool switch.
|
|
@@ -28,7 +29,7 @@ import type { PiTeamsToolResult } from "../../tool-result.ts";
|
|
|
28
29
|
import { result, type TeamContext } from "../context.ts";
|
|
29
30
|
import { handleExplain } from "../explain.ts";
|
|
30
31
|
import { handleHealthMonitor } from "../health-monitor.ts";
|
|
31
|
-
import { handleArtifacts, handleEvents, handleSummary } from "../inspect.ts";
|
|
32
|
+
import { handleArtifacts, handleBreakdown, handleEvents, handleSummary } from "../inspect.ts";
|
|
32
33
|
import { handleWorktrees } from "../lifecycle-actions.ts";
|
|
33
34
|
import { handleStatus } from "../status.ts";
|
|
34
35
|
|
|
@@ -45,6 +46,7 @@ export const STATUS_DOMAIN_ACTIONS = [
|
|
|
45
46
|
"get",
|
|
46
47
|
"events",
|
|
47
48
|
"artifacts",
|
|
49
|
+
"breakdown",
|
|
48
50
|
"summary",
|
|
49
51
|
"graph",
|
|
50
52
|
"search",
|
|
@@ -73,6 +75,8 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
|
|
|
73
75
|
return handleEvents(params, ctx);
|
|
74
76
|
case "artifacts":
|
|
75
77
|
return handleArtifacts(params, ctx);
|
|
78
|
+
case "breakdown":
|
|
79
|
+
return handleBreakdown(params, ctx);
|
|
76
80
|
case "worktrees":
|
|
77
81
|
return handleWorktrees(params, ctx);
|
|
78
82
|
case "summary":
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
2
4
|
import { readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
3
5
|
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
@@ -110,3 +112,91 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
110
112
|
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
111
113
|
});
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
// ─── G18 (SDD 2026-09-30 WI-3): prompt-token breakdown surface ──────────────
|
|
117
|
+
|
|
118
|
+
/** File suffix of the per-task breakdown artifact written by pre-execution
|
|
119
|
+
* (`metadata/<taskId>.prompt-breakdown.json`) when PI_CREW_PROMPT_BREAKDOWN=1. */
|
|
120
|
+
const BREAKDOWN_FILE_SUFFIX = ".prompt-breakdown.json";
|
|
121
|
+
|
|
122
|
+
/** Shape of one section entry in a prompt-breakdown JSON artifact
|
|
123
|
+
* (see writeArtifact call in src/runtime/task-runner/pre-execution.ts). */
|
|
124
|
+
interface PromptBreakdownSection {
|
|
125
|
+
chars: number;
|
|
126
|
+
estTokens: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* G18: read the opt-in per-task prompt-token breakdown artifacts of a run and
|
|
131
|
+
* surface per-task totals + the top-5 largest sections. Consumers: the leader
|
|
132
|
+
* prompt (future W-F token decisions, G15/G17) and humans debugging prompt
|
|
133
|
+
* bloat. The artifacts exist only when the host session was started with
|
|
134
|
+
* PI_CREW_PROMPT_BREAKDOWN=1 (default off — zero cost when off, SR-02).
|
|
135
|
+
* Mirrors the handleArtifacts pattern: resolve run → read the manifest
|
|
136
|
+
* artifacts index → filter kind=metadata breakdown entries → read files.
|
|
137
|
+
*/
|
|
138
|
+
export function handleBreakdown(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
139
|
+
if (!params.runId)
|
|
140
|
+
return result(
|
|
141
|
+
paramRequired("breakdown", "runId", "{ action: 'breakdown', runId: 'team_...' }"),
|
|
142
|
+
{ action: "breakdown", status: "error" },
|
|
143
|
+
true,
|
|
144
|
+
);
|
|
145
|
+
const runCwd = locateRunCwd(params.runId, ctx.cwd);
|
|
146
|
+
if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
|
|
147
|
+
const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
148
|
+
if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
|
|
149
|
+
// Index-driven lookup (no disk scan): the manifest artifacts index carries
|
|
150
|
+
// one metadata entry per task with a prompt breakdown.
|
|
151
|
+
const breakdownArtifacts = loaded.manifest.artifacts.filter(
|
|
152
|
+
(artifact) => artifact.kind === "metadata" && path.basename(artifact.path).endsWith(BREAKDOWN_FILE_SUFFIX),
|
|
153
|
+
);
|
|
154
|
+
const perTask: Array<{
|
|
155
|
+
taskId: string;
|
|
156
|
+
totalEstTokens: number;
|
|
157
|
+
topSections: Array<{ section: string; estTokens: number; chars: number }>;
|
|
158
|
+
}> = [];
|
|
159
|
+
for (const artifact of breakdownArtifacts) {
|
|
160
|
+
const taskId = path.basename(artifact.path).slice(0, -BREAKDOWN_FILE_SUFFIX.length);
|
|
161
|
+
if (params.taskId && taskId !== params.taskId) continue;
|
|
162
|
+
let sections: Record<string, PromptBreakdownSection>;
|
|
163
|
+
try {
|
|
164
|
+
sections = JSON.parse(fs.readFileSync(artifact.path, "utf-8")) as Record<string, PromptBreakdownSection>;
|
|
165
|
+
} catch {
|
|
166
|
+
continue; // unreadable/corrupt breakdown JSON — skip rather than fail the whole action
|
|
167
|
+
}
|
|
168
|
+
const entries = Object.entries(sections).filter((entry): entry is [string, PromptBreakdownSection] => {
|
|
169
|
+
const value = entry[1];
|
|
170
|
+
return value !== null && typeof value === "object" && typeof value.estTokens === "number";
|
|
171
|
+
});
|
|
172
|
+
const totalEstTokens = entries.reduce((sum, [, value]) => sum + value.estTokens, 0);
|
|
173
|
+
const topSections = [...entries]
|
|
174
|
+
.sort((a, b) => b[1].estTokens - a[1].estTokens)
|
|
175
|
+
.slice(0, 5)
|
|
176
|
+
.map(([section, value]) => ({ section, estTokens: value.estTokens, chars: value.chars ?? 0 }));
|
|
177
|
+
perTask.push({ taskId, totalEstTokens, topSections });
|
|
178
|
+
}
|
|
179
|
+
if (perTask.length === 0) {
|
|
180
|
+
const scope = params.taskId ? ` for task '${params.taskId}'` : "";
|
|
181
|
+
return result(
|
|
182
|
+
`No prompt-breakdown artifacts${scope} for ${loaded.manifest.runId}. Breakdown capture is opt-in: start the host Pi session with PI_CREW_PROMPT_BREAKDOWN=1 and re-run to record per-section token estimates.`,
|
|
183
|
+
{ action: "breakdown", status: "ok", runId: loaded.manifest.runId, data: { runTotal: 0, perTask: [] } },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const runTotal = perTask.reduce((sum, task) => sum + task.totalEstTokens, 0);
|
|
187
|
+
const lines = [
|
|
188
|
+
`Prompt-token breakdown for ${loaded.manifest.runId}:`,
|
|
189
|
+
...perTask.flatMap((task) => [
|
|
190
|
+
`- ${task.taskId}: ~${task.totalEstTokens} tokens`,
|
|
191
|
+
...task.topSections.map((section) => ` - ${section.section}: ~${section.estTokens} tokens (${section.chars} chars)`),
|
|
192
|
+
]),
|
|
193
|
+
`Run total: ~${runTotal} tokens`,
|
|
194
|
+
];
|
|
195
|
+
return result(lines.join("\n"), {
|
|
196
|
+
action: "breakdown",
|
|
197
|
+
status: "ok",
|
|
198
|
+
runId: loaded.manifest.runId,
|
|
199
|
+
artifactsRoot: loaded.manifest.artifactsRoot,
|
|
200
|
+
data: { runTotal, perTask },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { renderAskAnswer } from "../../prompt/prompt-runtime.ts";
|
|
2
2
|
import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
3
3
|
import { isWaitingWorkerAlive } from "../../runtime/dispatch-batch.ts";
|
|
4
|
+
import { checkProcessLiveness } from "../../runtime/process-status.ts";
|
|
4
5
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
5
6
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
6
7
|
import { appendMailboxMessage, readMailbox, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
|
|
@@ -142,6 +143,17 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
142
143
|
/** Exactly-once no-ops: a mailbox response already exists for the questionId. */
|
|
143
144
|
const noopIds: string[] = [];
|
|
144
145
|
const answeredEvents: Array<{ taskId: string; questionId: string; delivery: "mailbox" | "requeue" }> = [];
|
|
146
|
+
/** G5 (deep-review 2026-10-01): DEAD parks — the questionId-tagged response
|
|
147
|
+
* can never be picked up (the parked poller is gone); recorded with the
|
|
148
|
+
* liveness pid evidence so the leader sees the respond fell into the void.
|
|
149
|
+
* Twin of the broker-side task.respond_delivered ack. */
|
|
150
|
+
const missedEvents: Array<{
|
|
151
|
+
taskId: string;
|
|
152
|
+
questionId: string;
|
|
153
|
+
workerPid: number | undefined;
|
|
154
|
+
pidAlive: boolean;
|
|
155
|
+
pidDetail: string;
|
|
156
|
+
}> = [];
|
|
145
157
|
let updatedTasks = fresh.tasks;
|
|
146
158
|
|
|
147
159
|
for (const task of targetTasks) {
|
|
@@ -206,6 +218,21 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
206
218
|
updatedTasks = requeueWaitingTask(updatedTasks, task.id, message, renderAskAnswer(questionId, message));
|
|
207
219
|
requeuedIds.push(task.id);
|
|
208
220
|
answeredEvents.push({ taskId: task.id, questionId, delivery: "requeue" });
|
|
221
|
+
// G5: liveness evidence for respond_missed — the same pid source + probe
|
|
222
|
+
// the stale-reconciler / heartbeat-watcher use (heartbeat pid, fallback
|
|
223
|
+
// checkpoint childPid, then process.kill(pid,0) via checkProcessLiveness).
|
|
224
|
+
// A live pid here (stale heartbeat but process alive) is still recorded
|
|
225
|
+
// verbatim — the discriminator already ruled DEAD for delivery; the event
|
|
226
|
+
// reports what the pid probe saw, it does not re-decide the branch.
|
|
227
|
+
const missedPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
|
|
228
|
+
const liveness = checkProcessLiveness(missedPid);
|
|
229
|
+
missedEvents.push({
|
|
230
|
+
taskId: task.id,
|
|
231
|
+
questionId,
|
|
232
|
+
workerPid: missedPid,
|
|
233
|
+
pidAlive: liveness.alive,
|
|
234
|
+
pidDetail: liveness.detail,
|
|
235
|
+
});
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
// If this respond includes a replyTo, update the original message with reply metadata.
|
|
@@ -255,6 +282,33 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
255
282
|
),
|
|
256
283
|
);
|
|
257
284
|
}
|
|
285
|
+
// G5 (deep-review 2026-10-01): respond_missed — the DEAD-branch twin of
|
|
286
|
+
// the broker's respond_delivered ack. Same H1 fire-and-forget discipline
|
|
287
|
+
// (sync run-lock callback, never await/block); carries the questionId and
|
|
288
|
+
// the pid/liveness evidence the discrimination was based on.
|
|
289
|
+
for (const missed of missedEvents) {
|
|
290
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
291
|
+
type: "task.respond_missed",
|
|
292
|
+
runId: manifest.runId,
|
|
293
|
+
taskId: missed.taskId,
|
|
294
|
+
message: `Leader respond missed: worker for question ${missed.questionId} is not alive to receive the mailbox response (pid ${
|
|
295
|
+
missed.workerPid ?? "none recorded"
|
|
296
|
+
}: ${missed.pidDetail}); delivery rides the requeue+inject path.`,
|
|
297
|
+
data: {
|
|
298
|
+
questionId: missed.questionId,
|
|
299
|
+
...(missed.workerPid !== undefined ? { workerPid: missed.workerPid } : {}),
|
|
300
|
+
pidAlive: missed.pidAlive,
|
|
301
|
+
pidDetail: missed.pidDetail,
|
|
302
|
+
delivery: "requeue",
|
|
303
|
+
},
|
|
304
|
+
}).catch((error) =>
|
|
305
|
+
logInternalError(
|
|
306
|
+
"respond.respond-missed-event",
|
|
307
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
308
|
+
`runId=${manifest.runId}`,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
258
312
|
for (const requeuedId of requeuedIds) {
|
|
259
313
|
void appendEventAsync(manifest.eventsPath, {
|
|
260
314
|
type: "task.resumed",
|
|
@@ -15,6 +15,7 @@ import { appendEvent, readEventsCursor } from "../../state/event-log/event-log.t
|
|
|
15
15
|
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
16
16
|
import { aggregateUsage, formatCost, formatUsage } from "../../state/usage.ts";
|
|
17
17
|
import { formatDuration } from "../../ui/format-helpers.ts";
|
|
18
|
+
import { goalAchievedStatusLabel } from "../../ui/goal-flag.ts";
|
|
18
19
|
import { locateRunCwd } from "../team-tool.ts";
|
|
19
20
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
20
21
|
import { result, type TeamContext } from "./context.ts";
|
|
@@ -236,11 +237,16 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
236
237
|
const waitingTasks = tasks.filter((task) => task.status === "queued" || task.status === "waiting");
|
|
237
238
|
const agentLine = (agent: (typeof crewAgents)[number]): string =>
|
|
238
239
|
`- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.usage?.cost ? ` cost=${formatCost(agent.usage.cost)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
|
|
240
|
+
// G19 (W-E Phase 1): surface the goal-achievement verdict on terminal runs —
|
|
241
|
+
// ⚠ false-green warning line; silent while running, when achieved, or when
|
|
242
|
+
// the run predates the assessment (goalAchieved undefined).
|
|
243
|
+
const goalLine = goalAchievedStatusLabel(manifest);
|
|
239
244
|
const lines = [
|
|
240
245
|
`Run: ${manifest.runId}`,
|
|
241
246
|
`Team: ${manifest.team}`,
|
|
242
247
|
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
243
248
|
`Status: ${manifest.status}`,
|
|
249
|
+
...(goalLine ? [goalLine] : []),
|
|
244
250
|
`Progress: ${phaseProgress.overallPercentage}% (~${formatDuration(phaseProgress.estimatedRemainingMs)} remaining)`,
|
|
245
251
|
`Workspace mode: ${manifest.workspaceMode}`,
|
|
246
252
|
...(manifest.runtimeResolution
|
|
@@ -347,6 +353,8 @@ export function buildCompactStatus(
|
|
|
347
353
|
status: string;
|
|
348
354
|
goal: string;
|
|
349
355
|
workspaceMode?: string;
|
|
356
|
+
goalAchieved?: boolean | "unknown";
|
|
357
|
+
goalAchievementNote?: string;
|
|
350
358
|
},
|
|
351
359
|
tasks: Array<{
|
|
352
360
|
id: string;
|
|
@@ -360,10 +368,12 @@ export function buildCompactStatus(
|
|
|
360
368
|
progress?: { overallPercentage: number; estimatedRemainingMs: number },
|
|
361
369
|
): string[] {
|
|
362
370
|
const failedOrAttention = tasks.filter((t) => t.status === "failed" || t.status === "needs_attention" || t.status === "cancelled");
|
|
371
|
+
const goalLine = goalAchievedStatusLabel(manifest);
|
|
363
372
|
const lines = [
|
|
364
373
|
`Run: ${manifest.runId}`,
|
|
365
374
|
`Team: ${manifest.team}${manifest.workflow ? ` (${manifest.workflow})` : ""}`,
|
|
366
375
|
`Status: ${manifest.status}`,
|
|
376
|
+
...(goalLine ? [goalLine] : []),
|
|
367
377
|
...(progress ? [`Progress: ${progress.overallPercentage}% (~${formatDuration(progress.estimatedRemainingMs)} remaining)`] : []),
|
|
368
378
|
`Goal: ${manifest.goal}`,
|
|
369
379
|
...(asyncLivenessLine ? [asyncLivenessLine] : []),
|