pi-goal-list-loop-audit 0.38.21 → 0.38.24
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 +22 -0
- package/README.md +10 -0
- package/docs/DESIGN-subagent-visibility.md +38 -5
- package/docs/INDEX.md +5 -3
- package/docs/SETTINGS.md +85 -0
- package/extensions/auditor-extensions.ts +19 -6
- package/extensions/completion-summary.ts +8 -3
- package/extensions/context-checkpoint.ts +26 -42
- package/extensions/drafter-model.ts +8 -1
- package/extensions/goal-agents-panel.ts +121 -35
- package/extensions/goal-commands.ts +42 -5
- package/extensions/goal-compactor.ts +42 -2
- package/extensions/goal-continuation.ts +80 -8
- package/extensions/goal-heartbeat.ts +12 -0
- package/extensions/goal-loop-core.ts +90 -0
- package/extensions/goal-loop-display.ts +154 -52
- package/extensions/goal-loop-shield.ts +31 -0
- package/extensions/goal-recovery.ts +24 -5
- package/extensions/goal-settings.ts +84 -2
- package/extensions/loops/goal-activation.ts +31 -5
- package/extensions/loops/goal-auditor-hooks.ts +1 -0
- package/extensions/loops/goal-list-queue.ts +9 -0
- package/extensions/loops/goal-orchestrator.ts +5 -1
- package/extensions/loops/goal-runtime-globals.ts +24 -0
- package/extensions/loops/goal-session.ts +40 -5
- package/extensions/loops/goal-settings-ui.ts +48 -7
- package/extensions/loops/goal-tools.ts +6 -4
- package/extensions/loops/goal-ui.ts +21 -12
- package/extensions/model-selector.ts +13 -5
- package/extensions/multi-model-picker.ts +12 -0
- package/extensions/settings-menu.ts +13 -2
- package/extensions/state-root-owner.ts +79 -4
- package/package.json +1 -1
- package/scripts/release-pack-smoke.mjs +4 -1
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// in tailChildTranscript through an injected reader so tests stay hermetic.
|
|
12
12
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import { paint, truncateCells, type DisplayTheme } from "./goal-loop-display.js";
|
|
15
|
+
import { bucketSilentMs, fmtDuration, lifesignBandFor, sanitizeDisplayText } from "./goal-loop-core.js";
|
|
15
16
|
|
|
16
17
|
/** v0.35.45 (audit finding): the candidate scan reads a bounded TAIL of each
|
|
17
18
|
* transcript, not the whole file — up to 25 files were previously read in
|
|
@@ -43,6 +44,10 @@ export interface AgentsPanelRow {
|
|
|
43
44
|
outputTokens: number;
|
|
44
45
|
silentMs: number;
|
|
45
46
|
evidence: "record-frozen" | "event-only" | "live";
|
|
47
|
+
/** In-flight parent tool wait this live child holds (audit 2026-09-06:
|
|
48
|
+
* the doc-promised `└ blocks:` row). Set only from observed
|
|
49
|
+
* in-flight subagent-wait tool calls — never inferred. */
|
|
50
|
+
blockedBy?: string;
|
|
46
51
|
action?: "abort-requested" | "unavailable" | "failed";
|
|
47
52
|
endedOk?: boolean;
|
|
48
53
|
endedAt?: number;
|
|
@@ -90,21 +95,24 @@ function rowStateWord(row: AgentsPanelRow, now: number): string {
|
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
export function truncate(text: string, max: number): string {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
function fmtDuration(ms: number): string {
|
|
97
|
-
const totalSec = Math.max(0, Math.round(ms / 1000));
|
|
98
|
-
const min = Math.floor(totalSec / 60);
|
|
99
|
-
const sec = totalSec % 60;
|
|
100
|
-
if (min >= 60) return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}m`;
|
|
101
|
-
if (min > 0) return `${min}m${String(sec).padStart(2, "0")}s`;
|
|
102
|
-
return `${sec}s`;
|
|
98
|
+
// Audit 2026-09-06: cell-aware shared helper — the char slice overran on
|
|
99
|
+
// wide glyphs and could split surrogate pairs. ASCII contract unchanged.
|
|
100
|
+
return truncateCells(text, max);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
const PANEL_ROW_CAP = 20;
|
|
106
104
|
export const WIDGET_AGENT_ROW_CAP = 8;
|
|
107
105
|
|
|
106
|
+
/** Audit 2026-09-07 (field screenshots): the age label never says "silent" — that
|
|
107
|
+
* noun reads as a state claim ("this child is silent") while the child is
|
|
108
|
+
* visibly working. Fresh bands read `active {age}`, everything else
|
|
109
|
+
* `quiet {age}`: both are time-since-last-evidence, matching the band
|
|
110
|
+
* colors. Exported for the count line + panel detail sharing. */
|
|
111
|
+
export function activeAgeLabel(row: AgentsPanelRow): string {
|
|
112
|
+
const d = fmtDuration(bucketSilentMs(row.silentMs));
|
|
113
|
+
return lifesignBandFor(row) === "fresh" ? `active ${d}` : `quiet ${d}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
function rowRank(row: AgentsPanelRow): number {
|
|
109
117
|
if (row.status === "hung") return 0;
|
|
110
118
|
if (row.status === "running") return 1;
|
|
@@ -116,9 +124,17 @@ function orderedRows(rows: AgentsPanelRow[]): AgentsPanelRow[] {
|
|
|
116
124
|
return [...rows].sort((a, b) => rowRank(a) - rowRank(b) || b.silentMs - a.silentMs);
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
/** Compose the `└ blocks:` label from observed in-flight parent waits.
|
|
128
|
+
* Pure helper so the snapshot assembly and tests share the wording. */
|
|
129
|
+
export function blockedByLabel(waitNames: string[]): string | undefined {
|
|
130
|
+
const names = [...new Set(waitNames.filter(Boolean))];
|
|
131
|
+
if (names.length === 0) return undefined;
|
|
132
|
+
return `parent subagent wait (${names.join("/")})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
/** Render the /glla agents table. Hung/running/queued first, then ended;
|
|
120
136
|
* capped at PANEL_ROW_CAP rows with an explicit truncation notice. */
|
|
121
|
-
export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAvailable: boolean): string[] {
|
|
137
|
+
export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAvailable: boolean, recentHangs: string[] = []): string[] {
|
|
122
138
|
if (rows.length === 0) {
|
|
123
139
|
return ["No subagents tracked yet — spawn one via the `subagent` tool and it appears here.", "(evidence: glla's event probes" + (managerAvailable ? " + pi-subagents manager records" : "") + ")"];
|
|
124
140
|
}
|
|
@@ -128,7 +144,7 @@ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAv
|
|
|
128
144
|
for (const row of shown) {
|
|
129
145
|
const glyph = row.status === "ended" ? "✓" : row.status === "hung" ? "⚠" : "●";
|
|
130
146
|
lines.push(`${glyph} ${rowLabel(row)} ${rowStateWord(row, now)}`);
|
|
131
|
-
lines.push(` id ${cleanField(row.recordId, 18)} ·
|
|
147
|
+
lines.push(` id ${cleanField(row.recordId, 18)} · ${activeAgeLabel(row)} · tools ${row.toolUses} · out ${row.outputTokens >= 1000 ? `${(row.outputTokens / 1000).toFixed(1)}k` : row.outputTokens}${row.evidence !== "live" ? ` · ${row.evidence}` : ""}`);
|
|
132
148
|
if (row.action === "abort-requested") {
|
|
133
149
|
lines.push(" └ child-specific abort requested; partial output remains available while it settles");
|
|
134
150
|
} else if (row.action === "unavailable") {
|
|
@@ -138,33 +154,106 @@ export function renderAgentsPanel(rows: AgentsPanelRow[], now: number, managerAv
|
|
|
138
154
|
} else if (row.status === "hung") {
|
|
139
155
|
lines.push(" └ check the Agents panel: a child whose counters stopped moving is hung, not thinking");
|
|
140
156
|
}
|
|
157
|
+
// Audit 2026-09-06: the doc-promised `└ blocks:` row — only rendered
|
|
158
|
+
// from an observed in-flight wait (blockedBy set at snapshot time).
|
|
159
|
+
if (row.blockedBy && row.status !== "ended") {
|
|
160
|
+
lines.push(` └ blocks: ${truncate(row.blockedBy, 80)} (zombie stand-down active)`);
|
|
161
|
+
}
|
|
141
162
|
}
|
|
142
163
|
if (ordered.length > shown.length) {
|
|
143
164
|
lines.push(`… ${ordered.length - shown.length} more (oldest ended trimmed — cap ${PANEL_ROW_CAP})`);
|
|
144
165
|
}
|
|
166
|
+
// Audit 2026-09-06: the doc-promised "Recent hangs" footer, fed from
|
|
167
|
+
// the durable subagent_hang_detected ledger by the caller. Absent when
|
|
168
|
+
// there is nothing to show — never a placeholder row.
|
|
169
|
+
const hangs = recentHangs.filter(Boolean).slice(-3);
|
|
170
|
+
if (hangs.length > 0) {
|
|
171
|
+
lines.push(`Recent hangs: ${hangs.map((h) => truncate(h, 60)).join(" · ")}`);
|
|
172
|
+
}
|
|
145
173
|
return lines;
|
|
146
174
|
}
|
|
147
175
|
|
|
148
|
-
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
|
|
176
|
+
/** Single-line glyph-first worker rows for the below-chat widget (v0.38.23
|
|
177
|
+
* Option-2 shape). One row per tracked child: state reads from the leading
|
|
178
|
+
* glyph (▶ running, ◉ abort in flight, ⚠ hung/unavailable/failed) so no
|
|
179
|
+
* state word is needed for the healthy case; the silence age sits before
|
|
180
|
+
* any trailing suffix so narrow-terminal truncation cuts the least
|
|
181
|
+
* important field first. Ids live in the /glla agents table, not here.
|
|
182
|
+
* Overflow names its count without a command hint (v0.38.23: extension
|
|
183
|
+
* meta is noise). Silence ages stay bucketed — raw per-second values
|
|
184
|
+
* churn the widget key and re-layout the editor every tick (v0.37.1).
|
|
185
|
+
* v0.38.23 lifesign: the glyph also takes the semantic band color
|
|
186
|
+
* (success <5m, warning to 30m, error past it) — color never rides alone,
|
|
187
|
+
* the shape plus the silence number carry the same meaning unpainted. */
|
|
188
|
+
export function renderAgentsWidgetLines(rows: AgentsPanelRow[], now = Date.now(), maxRows = WIDGET_AGENT_ROW_CAP, theme?: DisplayTheme): string[] {
|
|
152
189
|
const active = orderedRows(rows.filter((r) => r.status !== "ended"));
|
|
153
190
|
const shown = active.slice(0, Math.max(1, maxRows));
|
|
154
191
|
const lines: string[] = [];
|
|
155
192
|
for (const row of shown) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
193
|
+
const glyph = row.status === "hung" ? "⚠"
|
|
194
|
+
: row.action === "abort-requested" ? "◉"
|
|
195
|
+
: (row.action === "unavailable" || row.action === "failed") ? "⚠"
|
|
196
|
+
: "▶";
|
|
197
|
+
const band = lifesignBandFor(row);
|
|
198
|
+
const color = band === "fresh" ? "success" : band === "aging" ? "warning" : "error";
|
|
199
|
+
const action = row.action === "abort-requested" ? " · aborting"
|
|
200
|
+
: row.action === "unavailable" ? " · abort unavailable"
|
|
201
|
+
: row.action === "failed" ? " · abort failed" : "";
|
|
160
202
|
const evidence = row.evidence !== "live" ? ` · ${row.evidence}` : "";
|
|
161
|
-
|
|
162
|
-
lines.push(` ${rowStateWord(row, now)} · silent ${fmtDuration(row.silentMs)}${evidence}${action}`);
|
|
203
|
+
lines.push(`${paint(theme, color, glyph)} ${rowLabel(row)} · ${activeAgeLabel(row)}${action}${evidence}`);
|
|
163
204
|
}
|
|
164
|
-
if (active.length > shown.length) lines.push(`… ${active.length - shown.length} more agents
|
|
205
|
+
if (active.length > shown.length) lines.push(`… ${active.length - shown.length} more agents`);
|
|
165
206
|
return lines;
|
|
166
207
|
}
|
|
167
208
|
|
|
209
|
+
/** Safety invariant for the richness ladder — HUNG/aborting/failed
|
|
210
|
+
* workers are never silent, so `quiet` still surfaces them. Audit
|
|
211
|
+
* 2026-09-07: also covers action failed/unavailable, matching the
|
|
212
|
+
* lifesignBandFor hung classification and the red rows — the count line
|
|
213
|
+
* and the rows can never disagree about trouble. */
|
|
214
|
+
export function hasHungWorker(rows: AgentsPanelRow[]): boolean {
|
|
215
|
+
return rows.some((r) => r.status !== "ended"
|
|
216
|
+
&& (r.status === "hung"
|
|
217
|
+
|| r.action === "abort-requested"
|
|
218
|
+
|| r.action === "failed"
|
|
219
|
+
|| r.action === "unavailable"));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export type AgentsExtras = { line: string; lines: string[] };
|
|
223
|
+
|
|
224
|
+
/** v0.38.22 (display unification): the pure richness switch behind the
|
|
225
|
+
* widget/status worker presence. Rich restores detailed single-line rows
|
|
226
|
+
* (capped, bucketed — the widget key only moves on genuine state
|
|
227
|
+
* transitions); compact keeps the count line; quiet surfaces
|
|
228
|
+
* hung/aborting workers only. Pure and hermetic for tests; the caller
|
|
229
|
+
* supplies rows + richness. v0.38.23: the task-linkage header is gone —
|
|
230
|
+
* the card head already names the objective, so `→ <objective>` only
|
|
231
|
+
* repeated it verbatim on the next row. */
|
|
232
|
+
export function assembleAgentsExtras(
|
|
233
|
+
rows: AgentsPanelRow[],
|
|
234
|
+
richness: "rich" | "compact" | "quiet",
|
|
235
|
+
now = Date.now(),
|
|
236
|
+
theme?: DisplayTheme,
|
|
237
|
+
): AgentsExtras | undefined {
|
|
238
|
+
const line = renderAgentsWidgetLine(rows);
|
|
239
|
+
if (!line) return undefined;
|
|
240
|
+
// Audit 2026-09-06 (DECIDED: show count line): quiet hides only when
|
|
241
|
+
// zero are tracked (line undefined above). Tracked-but-healthy keeps
|
|
242
|
+
// the compact count line — hiding healthy fan-out entirely cost ambient
|
|
243
|
+
// awareness. HUNG still surfaces via the ⚠ in the line itself.
|
|
244
|
+
// Audit 2026-09-07 (DECIDED: exceptions-only by default): quiet renders
|
|
245
|
+
// troubled (non-fresh-band) rows only — healthy fan-out lives on the
|
|
246
|
+
// native fleet panel, which shows it in more detail. The count line
|
|
247
|
+
// stays at every level (2026-09-06 ambient-awareness decision); HUNG is
|
|
248
|
+
// never silent at any level.
|
|
249
|
+
if (richness === "compact") return { line, lines: [] };
|
|
250
|
+
if (richness === "quiet") {
|
|
251
|
+
const troubled = rows.filter((r) => lifesignBandFor(r) !== "fresh");
|
|
252
|
+
return { line, lines: renderAgentsWidgetLines(troubled, now, WIDGET_AGENT_ROW_CAP, theme) };
|
|
253
|
+
}
|
|
254
|
+
return { line, lines: renderAgentsWidgetLines(rows, now, WIDGET_AGENT_ROW_CAP, theme) };
|
|
255
|
+
}
|
|
256
|
+
|
|
168
257
|
/** The compact footer summary: count + the least-live child.
|
|
169
258
|
* v0.37.1 (ui-jitter fix): bucket the silent age so the status/widget
|
|
170
259
|
* text stays stable between ticks — a per-second change made the footer
|
|
@@ -174,22 +263,19 @@ export function renderAgentsWidgetLine(rows: AgentsPanelRow[]): string | undefin
|
|
|
174
263
|
const active = rows.filter((r) => r.status !== "ended");
|
|
175
264
|
if (active.length === 0) return undefined;
|
|
176
265
|
const busiest = [...active].sort((a, b) => b.silentMs - a.silentMs)[0]!;
|
|
177
|
-
|
|
266
|
+
// Audit 2026-09-07 (HIGH): the trouble flag scans ALL live children.
|
|
267
|
+
// Checking only the stalest hid a hung/aborting child behind a healthy
|
|
268
|
+
// stalest sibling — HUNG-never-silent means any troubled child flags
|
|
269
|
+
// the line, while the name/age still describe the stalest child.
|
|
270
|
+
const hung = active.some((r) => r.action === "abort-requested")
|
|
178
271
|
? " ⚠ aborting"
|
|
179
|
-
:
|
|
272
|
+
: hasHungWorker(active)
|
|
180
273
|
? " ⚠"
|
|
181
274
|
: "";
|
|
182
|
-
return `● ${active.length} agent${active.length === 1 ? "" : "s"} · ${cleanField(busiest.agentType ?? "subagent", 18)}
|
|
275
|
+
return `● ${active.length} agent${active.length === 1 ? "" : "s"} · ${cleanField(busiest.agentType ?? "subagent", 18)} ${activeAgeLabel(busiest)}${hung}`;
|
|
183
276
|
}
|
|
184
277
|
|
|
185
|
-
|
|
186
|
-
* <1m → 5s buckets, <5m → 15s, otherwise 30s. The underlying
|
|
187
|
-
* hung classification still uses the exact value. */
|
|
188
|
-
function bucketSilentMs(ms: number): number {
|
|
189
|
-
if (ms < 60_000) return Math.floor(ms / 5000) * 5000;
|
|
190
|
-
if (ms < 300_000) return Math.floor(ms / 15_000) * 15_000;
|
|
191
|
-
return Math.floor(ms / 30_000) * 30_000;
|
|
192
|
-
}
|
|
278
|
+
|
|
193
279
|
|
|
194
280
|
export interface TranscriptTailResult {
|
|
195
281
|
ok: boolean;
|
|
@@ -1815,7 +1815,9 @@ function parseToolOverrideValue(s: string): unknown {
|
|
|
1815
1815
|
/** v0.27.5: /glla reviewer | postaudit — the post-completion audit config menu
|
|
1816
1816
|
* (project-scoped). Reads the dual-write settings (postaudit wins over the
|
|
1817
1817
|
* legacy reviewer key), and writes back to whichever key was read first —
|
|
1818
|
-
* so we don't drift two parallel config blocks.
|
|
1818
|
+
* so we don't drift two parallel config blocks. Writes to the legacy key
|
|
1819
|
+
* don't stick: load-migration consolidates them into postaudit on the
|
|
1820
|
+
* next read (audit 2026-09-07, finding 395). */
|
|
1819
1821
|
async function cmdReviewerSettings(ctx: ExtensionContext): Promise<void> {
|
|
1820
1822
|
const settings = loadSettings(ctx.cwd);
|
|
1821
1823
|
const block = (settings.postaudit ?? settings.reviewer) as Partial<ReviewerConfig> | undefined;
|
|
@@ -2459,11 +2461,19 @@ function cmdAgents(args: string, ctx: ExtensionContext): void {
|
|
|
2459
2461
|
const tailMatch = args.match(/--tail\s+(\S+)/);
|
|
2460
2462
|
if (tailMatch) {
|
|
2461
2463
|
const id = tailMatch[1]!;
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
+
// Audit 2026-09-06: an ambiguous prefix used to silently pick the
|
|
2465
|
+
// first match — name every candidate and let the user disambiguate.
|
|
2466
|
+
const exact = agents.find((a) => a.recordId === id);
|
|
2467
|
+
const cands = exact ? [exact] : agents.filter((a) => a.recordId.startsWith(id));
|
|
2468
|
+
if (cands.length === 0) {
|
|
2464
2469
|
ctx.ui.notify(`No tracked subagent matches "${id}". /glla agents lists the current ids.`, "warning");
|
|
2465
2470
|
return;
|
|
2466
2471
|
}
|
|
2472
|
+
if (cands.length > 1) {
|
|
2473
|
+
ctx.ui.notify(`"${id}" matches ${cands.length} tracked subagents — be more specific:\n${cands.map((a) => ` ${a.recordId} (${a.agentType ?? "subagent"}, ${a.status})`).join("\n")}`, "warning");
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const row = cands[0]!;
|
|
2467
2477
|
const linesMatch = args.match(/--lines\s+(\d+)/);
|
|
2468
2478
|
// v0.35.45 (audit finding): the candidate scan reads a bounded TAIL of
|
|
2469
2479
|
// each transcript instead of up to 25 FULL files synchronously on the
|
|
@@ -2499,7 +2509,24 @@ function cmdAgents(args: string, ctx: ExtensionContext): void {
|
|
|
2499
2509
|
ctx.ui.notify([header, ...result.lines, ...(result.ok ? [] : [result.detail])].join("\n"), result.ok ? "info" : "warning");
|
|
2500
2510
|
return;
|
|
2501
2511
|
}
|
|
2502
|
-
|
|
2512
|
+
// Audit 2026-09-06: the doc-promised "Recent hangs" footer — last 3
|
|
2513
|
+
// durable subagent_hang_detected events. Best-effort: a missing ledger
|
|
2514
|
+
// yields no footer, never an error row.
|
|
2515
|
+
let recentHangs: string[] = [];
|
|
2516
|
+
try {
|
|
2517
|
+
const now = Date.now();
|
|
2518
|
+
recentHangs = readLedgerTail(ctx.cwd, 25, (e) => e.type === "subagent_hang_detected")
|
|
2519
|
+
.slice(-3)
|
|
2520
|
+
.map((e) => {
|
|
2521
|
+
const v = (e.value ?? {}) as { agentType?: string; recordId?: string; at?: string };
|
|
2522
|
+
const ms = e.at ? now - Date.parse(e.at) : Number.NaN;
|
|
2523
|
+
const age = Number.isFinite(ms) && ms >= 0
|
|
2524
|
+
? ms < 3_600_000 ? `${Math.max(0, Math.round(ms / 60_000))}m` : `${Math.floor(ms / 3_600_000)}h${String(Math.floor(ms / 60_000) % 60).padStart(2, "0")}m`
|
|
2525
|
+
: "?";
|
|
2526
|
+
return `${v.agentType ?? "subagent"} ${age} ago`;
|
|
2527
|
+
});
|
|
2528
|
+
} catch { recentHangs = []; }
|
|
2529
|
+
ctx.ui.notify(`glla agents${managerAvailable ? "" : " (pi-subagents manager registry not present — event evidence only)"}\n${renderAgentsPanel(agents, Date.now(), managerAvailable, recentHangs).join("\n")}`, "info");
|
|
2503
2530
|
}
|
|
2504
2531
|
|
|
2505
2532
|
// v0.29.8: /glla status — the unified "what's running" surface (user: "we
|
|
@@ -2555,7 +2582,12 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2555
2582
|
// rather than through noisy inline assignments.
|
|
2556
2583
|
const trimmed = args.trim();
|
|
2557
2584
|
const verb = trimmed ? trimmed.split(/\s+/)[0]!.toLowerCase() : "ui";
|
|
2558
|
-
|
|
2585
|
+
// Audit 2026-09-07 (LOW, finding 399): bare `/glla fallbacks` only
|
|
2586
|
+
// displays the chain — only clear/off/unset/none mutates. Refusing the
|
|
2587
|
+
// read-only display on a stale handle blocks inspection for no safety.
|
|
2588
|
+
const fallbacksRest = verb === "fallbacks" ? trimmed.slice("fallbacks".length).trim().toLowerCase() : "";
|
|
2589
|
+
const fallbacksReadOnly = verb === "fallbacks" && !/^(clear|off|unset|none)(\s|$)/.test(fallbacksRest);
|
|
2590
|
+
if (staleEntry && (verb === "ui" || (!fallbacksReadOnly && SETTINGS_MUTATING_ACTIONS.has(verb)))) {
|
|
2559
2591
|
appendLedger(ctx.cwd, "settings_mutation_refused_stale", { sub: verb });
|
|
2560
2592
|
return;
|
|
2561
2593
|
}
|
|
@@ -2706,6 +2738,11 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2706
2738
|
fmt("visionAssist", "visionAssist"),
|
|
2707
2739
|
fmt("auditorModel", "auditorModel"),
|
|
2708
2740
|
fmt("auditorThinkingLevel", "thinking"),
|
|
2741
|
+
// Audit 2026-09-06: the headless fallback omitted these — headless
|
|
2742
|
+
// operators could not see the compactor chain or display richness.
|
|
2743
|
+
fmt("compactorModel", "compactorModel"),
|
|
2744
|
+
`compactorModelFallbacks: ${formatMainModelFallbacks(effectiveSettings.compactorModelFallbacks)} [${prov.compactorModelFallbacks?.source ?? "default"}]`,
|
|
2745
|
+
fmt("subagentDisplayRichness", "subagentDisplayRichness"),
|
|
2709
2746
|
fmt("notifyCmd", "notify"),
|
|
2710
2747
|
fmt("tokenLimit", "tokenLimit"),
|
|
2711
2748
|
fmt("autoResume", "autoResume"),
|
|
@@ -33,6 +33,9 @@ export const COMPACTOR_BRIEF_MAX_CHARS = 2000;
|
|
|
33
33
|
export const COMPACTOR_PACKET_MAX_CHARS = 6000;
|
|
34
34
|
/** Worker wall clock: a brief is one completion, not an agentic loop. */
|
|
35
35
|
export const COMPACTOR_TIMEOUT_MS = 180_000;
|
|
36
|
+
/** Audit 2026-09-06: grace between SIGTERM and SIGKILL when the worker
|
|
37
|
+
* overruns its timeout — SIGTERM alone can leave a stuck child alive. */
|
|
38
|
+
export const COMPACTOR_KILL_GRACE_MS = 5_000;
|
|
36
39
|
/** The compactor reasons as little as possible: compression, not judgment. */
|
|
37
40
|
export const COMPACTOR_THINKING = "minimal";
|
|
38
41
|
|
|
@@ -149,7 +152,19 @@ function defaultSpawnWorker(script: string, jobDir: string, request: Record<stri
|
|
|
149
152
|
}
|
|
150
153
|
const child = nodeSpawn(process.execPath, [script, "--job-dir", jobDir], { stdio: "ignore" });
|
|
151
154
|
const timer = setTimeout(() => {
|
|
152
|
-
|
|
155
|
+
// Audit 2026-09-06: SIGTERM first, SIGKILL fallback — a stuck worker
|
|
156
|
+
// must not survive the timeout as a zombie holding the job dir.
|
|
157
|
+
try {
|
|
158
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
159
|
+
child.kill("SIGTERM");
|
|
160
|
+
const killTimer = setTimeout(() => {
|
|
161
|
+
try {
|
|
162
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
163
|
+
} catch { /* already gone */ }
|
|
164
|
+
}, COMPACTOR_KILL_GRACE_MS);
|
|
165
|
+
killTimer.unref?.();
|
|
166
|
+
}
|
|
167
|
+
} catch { /* already gone */ }
|
|
153
168
|
done({ ok: false, error: "compactor worker timed out" });
|
|
154
169
|
}, (typeof request.timeoutMs === "number" && request.timeoutMs > 0 ? request.timeoutMs : COMPACTOR_TIMEOUT_MS) + 15_000);
|
|
155
170
|
timer.unref?.();
|
|
@@ -192,12 +207,37 @@ function pruneOldJobDirs(cwd: string, now = Date.now()): void {
|
|
|
192
207
|
* model (chain → plan B → skip), persists the brief, notifies + pages.
|
|
193
208
|
* Fire-and-forget safe: never throws.
|
|
194
209
|
*/
|
|
210
|
+
/** Durable one-shot marker — audit 2026-09-06: the in-memory refuse
|
|
211
|
+
* transition is process-local, so a restart mid-episode would re-fire the
|
|
212
|
+
* brief + page. The marker survives restarts; recovery (shouldRefuse=false)
|
|
213
|
+
* removes it to re-arm the next episode. */
|
|
214
|
+
export function compactorFiredMarkerPath(cwd: string): string {
|
|
215
|
+
return path.join(piGlaDir(cwd), "compactor-fired.json");
|
|
216
|
+
}
|
|
217
|
+
|
|
195
218
|
export async function runEmergencyCompactorIfDue(
|
|
196
219
|
ctx: Pick<ExtensionContext, "cwd" | "model" | "modelRegistry" | "getContextUsage">,
|
|
197
220
|
shouldRefuseNow: boolean,
|
|
198
221
|
deps: CompactorDeps = {},
|
|
199
222
|
): Promise<{ fired: boolean; briefChars?: number; via?: string }> {
|
|
200
|
-
if (!
|
|
223
|
+
if (!shouldRefuseNow) {
|
|
224
|
+
claimCompactorRefuseTransition(false);
|
|
225
|
+
try { fs.rmSync(compactorFiredMarkerPath(ctx.cwd), { force: true }); } catch { /* re-arm best effort */ }
|
|
226
|
+
return { fired: false };
|
|
227
|
+
}
|
|
228
|
+
// Restart mid-episode: the marker says this episode already fired.
|
|
229
|
+
// Converge the in-memory transition (claim it) and stay silent.
|
|
230
|
+
let alreadyFired = false;
|
|
231
|
+
try { alreadyFired = fs.existsSync(compactorFiredMarkerPath(ctx.cwd)); } catch { alreadyFired = false; }
|
|
232
|
+
if (alreadyFired) {
|
|
233
|
+
claimCompactorRefuseTransition(true);
|
|
234
|
+
return { fired: false };
|
|
235
|
+
}
|
|
236
|
+
if (!claimCompactorRefuseTransition(true)) return { fired: false };
|
|
237
|
+
try {
|
|
238
|
+
fs.mkdirSync(piGlaDir(ctx.cwd), { recursive: true });
|
|
239
|
+
fs.writeFileSync(compactorFiredMarkerPath(ctx.cwd), JSON.stringify({ at: new Date().toISOString() }) + "\n");
|
|
240
|
+
} catch { /* marker write is best effort; in-memory one-shot still holds this process */ }
|
|
201
241
|
try {
|
|
202
242
|
return await runEmergencyCompactor(ctx, deps);
|
|
203
243
|
} catch (error) {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readLedgerTail,
|
|
35
35
|
nowIso,
|
|
36
36
|
newGoalId,
|
|
37
|
+
archiveDir,
|
|
37
38
|
archivedGoalPath,
|
|
38
39
|
goalMdPath,
|
|
39
40
|
writeGoalMd,
|
|
@@ -257,6 +258,20 @@ let continuationScheduledFor: string | null = null;
|
|
|
257
258
|
|
|
258
259
|
let lastContinuationSentAt = 0;
|
|
259
260
|
|
|
261
|
+
// Audit 2026-09-07 (MEDIUM): wall-clock of the last genuine user-role
|
|
262
|
+
// message observed via message_start. A user message arriving after a
|
|
263
|
+
// dispatch was sent means the next agent_start/turn_start belongs to the
|
|
264
|
+
// user's manual turn, not the continuation — the fallback ack must refuse
|
|
265
|
+
// it so a manual turn can't hide a genuine no-start. Our own continuations
|
|
266
|
+
// send as customType (never role user) and our injected draft seed is
|
|
267
|
+
// skipped by the caller, so only real user turns stamp this.
|
|
268
|
+
let lastUserMessageAt = 0;
|
|
269
|
+
|
|
270
|
+
/** Record a genuine user message (message_start, role user, not our seed). */
|
|
271
|
+
export function noteUserMessageForDispatch(): void {
|
|
272
|
+
lastUserMessageAt = Date.now();
|
|
273
|
+
}
|
|
274
|
+
|
|
260
275
|
let queueStuckProbe: ReturnType<typeof setTimeout> | null = null;
|
|
261
276
|
|
|
262
277
|
// v0.28.5 (E3): send-retry re-arm accounting. The 50ms BACKOFF_IDLE_RETRY
|
|
@@ -653,7 +668,19 @@ export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string,
|
|
|
653
668
|
if (source === "before_agent_start") {
|
|
654
669
|
if (!dispatchPromptMatches(record, prompt)) return false;
|
|
655
670
|
} else if (source === "agent_start" || source === "turn_start") {
|
|
656
|
-
// fallback — no prompt to match
|
|
671
|
+
// fallback — no prompt to match. But a user message after the send
|
|
672
|
+
// means this turn is the user's manual turn, not the continuation:
|
|
673
|
+
// refuse so the watchdog keeps watching the genuine no-start.
|
|
674
|
+
// before_agent_start with the marker still acks (strongest proof).
|
|
675
|
+
if (lastUserMessageAt > record.sentAt) {
|
|
676
|
+
appendLedger(ctx.cwd, "continuation_start_ack_refused_user_turn", {
|
|
677
|
+
dispatchId: record.id,
|
|
678
|
+
source,
|
|
679
|
+
sentAt: record.sentAt,
|
|
680
|
+
lastUserMessageAt,
|
|
681
|
+
});
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
657
684
|
} else {
|
|
658
685
|
// message_update / agent_end / other liveness signals cannot settle
|
|
659
686
|
// without an existing proof, and pending.phase !== "accepted" already
|
|
@@ -934,9 +961,21 @@ export function guardGoalBeforeContinuation(
|
|
|
934
961
|
if (goal.status === "auditing" && !allowAuditing) return false;
|
|
935
962
|
if (goal.status !== "active" && goal.status !== "paused" && goal.status !== "auditing") return false;
|
|
936
963
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
964
|
+
// Audit 2026-09-06: archivedPath is durable state and may be corrupted —
|
|
965
|
+
// the old code existsSync-probed ANY absolute path it named (arbitrary
|
|
966
|
+
// filesystem existence oracle) and then nulled the live goal on a hit.
|
|
967
|
+
// Only honor it inside the archive dir; anything else is an anomaly that
|
|
968
|
+
// falls back to the canonical location.
|
|
969
|
+
let storedArchive = archivedGoalPath(ctx.cwd, goal.id);
|
|
970
|
+
if (goal.archivedPath) {
|
|
971
|
+
const candidate = path.isAbsolute(goal.archivedPath) ? goal.archivedPath : path.resolve(ctx.cwd, goal.archivedPath);
|
|
972
|
+
const rel = path.relative(archiveDir(ctx.cwd), candidate);
|
|
973
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
974
|
+
storedArchive = candidate;
|
|
975
|
+
} else {
|
|
976
|
+
appendLedger(ctx.cwd, "faulty_objective_archive_path_anomaly", { goalId: goal.id, where, archivedPath: String(goal.archivedPath).slice(0, 200) });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
940
979
|
if (fs.existsSync(storedArchive)) {
|
|
941
980
|
appendLedger(ctx.cwd, "faulty_objective_archive_fence", { goalId: goal.id, where, archive: storedArchive });
|
|
942
981
|
try { fs.rmSync(goalMdPath(ctx.cwd, goal.id), { force: true }); } catch { /* best effort */ }
|
|
@@ -1061,10 +1100,13 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
|
|
|
1061
1100
|
if (pendingContinuationDispatch) return;
|
|
1062
1101
|
if (continuationDispatchStoodDown && !force) return;
|
|
1063
1102
|
if (force) releaseContinuationDispatchStandDown();
|
|
1064
|
-
flags.abortedStandDown = false; // v0.29.5: any explicit schedule ends the stand-down
|
|
1065
1103
|
if (!isActionableGoal()) return;
|
|
1066
1104
|
if (!guardGoalBeforeContinuation(ctx, "schedule")) return;
|
|
1067
1105
|
if (!isActionableGoal()) return;
|
|
1106
|
+
// v0.29.5: an explicit schedule ends the stand-down — audit 2026-09-06:
|
|
1107
|
+
// only a schedule that actually arms clears it. Clearing before the
|
|
1108
|
+
// gates let no-op schedules discharge the user's abort latch.
|
|
1109
|
+
flags.abortedStandDown = false;
|
|
1068
1110
|
rememberCtx(ctx);
|
|
1069
1111
|
const goalId = state.goal!.id;
|
|
1070
1112
|
if (!force && continuationScheduledFor === goalId) return;
|
|
@@ -1102,6 +1144,10 @@ export interface TerminalCompletionNotice {
|
|
|
1102
1144
|
goalId: string;
|
|
1103
1145
|
outcome: string;
|
|
1104
1146
|
details: string[];
|
|
1147
|
+
/** Audit 2026-09-06: the session generation that produced the verdict.
|
|
1148
|
+
* A stale generation (verdict applied after handoff/reload) must not
|
|
1149
|
+
* fire a `✓ done` turn into the successor session's unrelated work. */
|
|
1150
|
+
generation?: number;
|
|
1105
1151
|
}
|
|
1106
1152
|
|
|
1107
1153
|
/** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
|
|
@@ -1118,8 +1164,26 @@ export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: Term
|
|
|
1118
1164
|
if (supervisorPaused(state)) return false;
|
|
1119
1165
|
if (mainModelRecoveryActive()) return false;
|
|
1120
1166
|
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
|
|
1167
|
+
// Audit 2026-09-07 (HIGH, scoped): refuse while the user-abort latch
|
|
1168
|
+
// stands — a `✓ done` turn firing under the user's hands after Esc/zombie
|
|
1169
|
+
// abort violates the stand-down promise. Deliberately NOT gated on
|
|
1170
|
+
// stoodDown/pending: closure wins there (a pending dispatch fences itself
|
|
1171
|
+
// on the archived goal), and dropping the notice would lose transcript
|
|
1172
|
+
// closure with no retry path.
|
|
1173
|
+
if (flags.abortedStandDown) {
|
|
1174
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_refused_stood_down", { goalId: notice.goalId });
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1121
1177
|
if (!flags.extensionApi) return false;
|
|
1122
1178
|
if (isForeignCtx(ctx)) return false;
|
|
1179
|
+
if (typeof notice.generation === "number" && notice.generation !== flags.sessionGeneration) {
|
|
1180
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_stale_generation", {
|
|
1181
|
+
goalId: notice.goalId,
|
|
1182
|
+
noticeGeneration: notice.generation,
|
|
1183
|
+
currentGeneration: flags.sessionGeneration,
|
|
1184
|
+
});
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1123
1187
|
try {
|
|
1124
1188
|
const already = readLedgerTail(ctx.cwd, 400, (entry) =>
|
|
1125
1189
|
entry.type === "terminal_completion_notice_sent" &&
|
|
@@ -1201,7 +1265,11 @@ export function sendContinuation(goalId: string): void {
|
|
|
1201
1265
|
// refuses new schedules; this closes the armed-timer race.
|
|
1202
1266
|
if (supervisorPaused(state)) return;
|
|
1203
1267
|
if (mainModelRecoveryActive()) return;
|
|
1204
|
-
|
|
1268
|
+
// Audit 2026-09-07 (HIGH): an armed timer must not fire after a user-Esc
|
|
1269
|
+
// or zombie abort. continuationDispatchStoodDown alone is insufficient —
|
|
1270
|
+
// explicit resume paths release it while flags.abortedStandDown stays set
|
|
1271
|
+
// until a schedule actually arms, so check the abort latch here too.
|
|
1272
|
+
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
|
|
1205
1273
|
continuationTimer = null;
|
|
1206
1274
|
continuationScheduledFor = null;
|
|
1207
1275
|
if (!state.goal || state.goal.id !== goalId) {
|
|
@@ -1341,7 +1409,9 @@ export function sendContinuation(goalId: string): void {
|
|
|
1341
1409
|
// call otherwise. display: true — the user should see the warning too.
|
|
1342
1410
|
export function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
|
|
1343
1411
|
if (supervisorPaused(state)) return;
|
|
1344
|
-
|
|
1412
|
+
// Audit 2026-09-07 (HIGH): a stall nudge must not resurrect a stood-down
|
|
1413
|
+
// chain — same abort-latch reasoning as sendContinuation.
|
|
1414
|
+
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
|
|
1345
1415
|
if (!state.goal || !guardGoalBeforeContinuation(ctx, "stall-escalation")) return;
|
|
1346
1416
|
const remaining = HEARTBEAT_MAX_NUDGES - nudges;
|
|
1347
1417
|
const text = [
|
|
@@ -1383,7 +1453,9 @@ export function sendStallEscalation(ctx: ExtensionContext, nudges: number): void
|
|
|
1383
1453
|
// plain sessions truncate too.
|
|
1384
1454
|
export function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
|
|
1385
1455
|
if (supervisorPaused(state)) return;
|
|
1386
|
-
|
|
1456
|
+
// Audit 2026-09-07 (HIGH): a length nudge must not resurrect a stood-down
|
|
1457
|
+
// chain — same abort-latch reasoning as sendContinuation.
|
|
1458
|
+
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || !flags.extensionApi || flags.extensionApiStale || continuationDispatchStoodDown || pendingContinuationDispatch || flags.abortedStandDown) return;
|
|
1387
1459
|
if (state.goal && !guardGoalBeforeContinuation(ctx, "length-continuation")) return;
|
|
1388
1460
|
const attempt = dispatchPrepare(ctx, {
|
|
1389
1461
|
generation: flags.sessionGeneration,
|
|
@@ -45,6 +45,7 @@ import { isLoopActive, loopTimerPending, scheduleLoopTick } from "./goal-loop.js
|
|
|
45
45
|
import { mainModelRecoveryActive, markCompletionAuditRecoveryPending, probeMainModelRecovery } from "./goal-recovery.js";
|
|
46
46
|
import type { ContinuationDispatch } from "./goal-loop-dispatch.js";
|
|
47
47
|
import type { AgentPhase, AgentStatus } from "./goal-agents-panel.js";
|
|
48
|
+
import { blockedByLabel } from "./goal-agents-panel.js";
|
|
48
49
|
import { ContinuousSupervisor, SUPERVISION_MAX_POLL_MS, type SupervisionSignal } from "./continuous-supervision.js";
|
|
49
50
|
|
|
50
51
|
/** goal.ts-owned module lets the heartbeat reads/writes through this accessor.
|
|
@@ -1661,6 +1662,8 @@ export interface SubagentAgentView {
|
|
|
1661
1662
|
/** ms since the last NEW progress (tool use / output tokens). */
|
|
1662
1663
|
silentMs: number;
|
|
1663
1664
|
evidence: "record-frozen" | "event-only" | "live";
|
|
1665
|
+
/** Observed in-flight parent subagent wait (audit 2026-09-06 `└ blocks:`). */
|
|
1666
|
+
blockedBy?: string;
|
|
1664
1667
|
hangAlertedAt?: number;
|
|
1665
1668
|
action?: "abort-requested" | "unavailable" | "failed";
|
|
1666
1669
|
endedAt?: number;
|
|
@@ -1671,6 +1674,14 @@ function finiteNonNegative(value: unknown): number | undefined {
|
|
|
1671
1674
|
}
|
|
1672
1675
|
|
|
1673
1676
|
export function getSubagentAgentsSnapshot(now = Date.now()): { agents: SubagentAgentView[]; managerAvailable: boolean } {
|
|
1677
|
+
// Audit 2026-09-06: the doc-promised `└ blocks:` row — label live rows
|
|
1678
|
+
// with the OBSERVED in-flight parent wait (tool args carry no run id, so
|
|
1679
|
+
// per-row correlation is impossible; the name is named, never inferred).
|
|
1680
|
+
let blockedBy: string | undefined;
|
|
1681
|
+
try {
|
|
1682
|
+
const waits = [...flags.inFlightToolCalls.values()].filter(isSubagentWaitCall).map((t) => t.name ?? "");
|
|
1683
|
+
blockedBy = blockedByLabel(waits);
|
|
1684
|
+
} catch { blockedBy = undefined; }
|
|
1674
1685
|
const poll = subagentManagerPoller();
|
|
1675
1686
|
const managerAvailable = typeof poll.getRecord === "function";
|
|
1676
1687
|
const agents: SubagentAgentView[] = [];
|
|
@@ -1726,6 +1737,7 @@ export function getSubagentAgentsSnapshot(now = Date.now()): { agents: SubagentA
|
|
|
1726
1737
|
outputTokens,
|
|
1727
1738
|
silentMs,
|
|
1728
1739
|
evidence,
|
|
1740
|
+
...(blockedBy && status !== "ended" ? { blockedBy } : {}),
|
|
1729
1741
|
...(probe.hangAlertedAt !== undefined ? { hangAlertedAt: probe.hangAlertedAt } : {}),
|
|
1730
1742
|
...(probe.hangAction !== undefined ? { action: probe.hangAction } : {}),
|
|
1731
1743
|
...(probe.endedAt !== undefined ? { endedAt: probe.endedAt } : {}),
|