pi-goal-list-loop-audit 0.38.17 → 0.38.18
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 +7 -0
- package/docs/INDEX.md +1 -1
- package/extensions/goal-continuation.ts +78 -2
- package/extensions/goal-loop-backoff.ts +12 -1
- package/extensions/goal-loop-shield.ts +179 -6
- package/extensions/loops/goal-activation.ts +26 -6
- package/extensions/loops/goal-auditor-hooks.ts +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.18 — completion/lifecycle field trilogy (2026-09-04)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Track 1 (pipe-syntax): the mechanical pre-audit checker runs narrow `cmd 2>&1 | tail/head/grep` pipelines shell-free with pipefail-head semantics instead of 126-rejecting finished work (`parseMechanicalPipeline`, `runMechanicalPipeline`; `tee`/grep-file-flags still refused).
|
|
7
|
+
- Track 2 (post-answer stall): a turn that never streamed parks on the first abort with no hot retry (`zombie_auto_retry_refused_never_streamed`, honest park copy), and the rearm milestone names an open-but-silent turn instead of claiming "no turn started".
|
|
8
|
+
- Track 3 (stale waiting-verdict): the detached-approval branch delivers the `✓ done` brief into the conversation as a fire-once followUp turn (`sendTerminalCompletionNotice`), so the transcript records the archive instead of narrating "waiting" forever. Skipped for manual `/goal verify` (in-turn closure already exists).
|
|
9
|
+
|
|
3
10
|
## 0.38.17 — release-contract docs trail (2026-09-04)
|
|
4
11
|
|
|
5
12
|
### Fixed
|
package/docs/INDEX.md
CHANGED
|
@@ -18,7 +18,7 @@ For shipped docs, the relevant entry points are:
|
|
|
18
18
|
failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
|
|
19
19
|
handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
|
|
20
20
|
shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
|
|
21
|
-
v0.35.14–v0.38.
|
|
21
|
+
v0.35.14–v0.38.18 continue through the supervisor freeze (`/glla pause`),
|
|
22
22
|
load hold, auditor picker parity, Windows launch fix, zombie-watchdog
|
|
23
23
|
subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
|
|
24
24
|
durable state-root selection, blank-until-resume auditor context, frozen
|
|
@@ -31,6 +31,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
31
31
|
import { state, replaceState } from "./goal-state.js";
|
|
32
32
|
import {
|
|
33
33
|
appendLedger,
|
|
34
|
+
readLedgerTail,
|
|
34
35
|
nowIso,
|
|
35
36
|
newGoalId,
|
|
36
37
|
archivedGoalPath,
|
|
@@ -267,6 +268,12 @@ let continuationRearmSince = 0;
|
|
|
267
268
|
// (rearm storm fires with no accepted dispatch — the user's "pi did not
|
|
268
269
|
// start a turn" state under a recovery park).
|
|
269
270
|
let lastNoTurnStartedNotifiedAt = 0;
|
|
271
|
+
// v0.38.18 (track 2): the wall clock of the last turn/agent begin-marker
|
|
272
|
+
// observed by dispatchStartAcknowledged, regardless of pending dispatch.
|
|
273
|
+
// Lets the milestone notify distinguish "no turn started" from "a turn is
|
|
274
|
+
// open but never picked up the dispatch" (neonbreak: creation turn open
|
|
275
|
+
// and silent since 12:57 while the storm reported "no turn started").
|
|
276
|
+
let lastObservedTurnStartAt = 0;
|
|
270
277
|
let continuationRearmMilestone = 0;
|
|
271
278
|
// v0.34.57: per-record counter capping the compaction-paused re-arm loop.
|
|
272
279
|
// A stuck session that never produces a new compaction event must not
|
|
@@ -348,10 +355,18 @@ export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "
|
|
|
348
355
|
const noDispatchAccepted = lastContinuationSentAt === 0 || lastContinuationSentAt < continuationRearmSince;
|
|
349
356
|
if (noDispatchAccepted && lastNoTurnStartedNotifiedAt + SEND_REARM_LEDGER_MILESTONES_MS[0]! <= Date.now()) {
|
|
350
357
|
lastNoTurnStartedNotifiedAt = Date.now();
|
|
351
|
-
|
|
358
|
+
// v0.38.18 (track 2): a turn that began after the storm started is
|
|
359
|
+
// open — reporting "no turn started" then is false and sends the
|
|
360
|
+
// user down the wrong path (resume a queue that is not stuck vs
|
|
361
|
+
// interrupt a hung turn). Say which is true.
|
|
362
|
+
const turnOpen = lastObservedTurnStartAt >= since && lastObservedTurnStartAt > 0;
|
|
363
|
+
const turnOpenMinutes = turnOpen ? Math.max(1, Math.round((Date.now() - lastObservedTurnStartAt) / 60000)) : 0;
|
|
364
|
+
appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000), turnOpen, turnOpenMinutes });
|
|
352
365
|
const aggressive = resolveEffectiveAggressiveSettings(loadSettings(ctx.cwd)).aggressiveMode;
|
|
353
366
|
const recoveryStop = aggressive ? "a state-based stop" : "the automatic horizon";
|
|
354
|
-
const msg =
|
|
367
|
+
const msg = turnOpen
|
|
368
|
+
? `glla: a turn started ${turnOpenMinutes}m ago but no continuation was accepted since (${streak} re-arms) — the open turn has not picked up the queued dispatch. If its stream counters are frozen it is hung, not thinking; Esc interrupts it, then resume retries. No action needed unless this reaches ${recoveryStop}.`
|
|
369
|
+
: `glla: pi accepted no continuation for ${Math.round(elapsed / 60000)}m (${streak} re-arms, no turn started) — the send queue may be stuck. The generic recovery probe is retrying automatically; no action needed unless it reaches ${recoveryStop}.`;
|
|
355
370
|
ctx.ui.notify(msg, "warning");
|
|
356
371
|
notifyExternal(ctx, msg);
|
|
357
372
|
}
|
|
@@ -569,6 +584,10 @@ export function dispatchFailed(ctx: ExtensionContext, record: ContinuationDispat
|
|
|
569
584
|
}
|
|
570
585
|
|
|
571
586
|
export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string, prompt?: unknown): boolean {
|
|
587
|
+
// v0.38.18 (track 2): record every begin-marker even when no dispatch is
|
|
588
|
+
// pending — the rearm milestone uses it to tell an open-but-silent turn
|
|
589
|
+
// from a turn that never started.
|
|
590
|
+
if (source === "turn_start" || source === "agent_start") lastObservedTurnStartAt = Date.now();
|
|
572
591
|
// v0.34.104 ([Image-#1]): any real agent activity during the
|
|
573
592
|
// post-list-completion settle window means pi woke up on its own — the
|
|
574
593
|
// deferred continuation must be cancelled so we don't double-dispatch.
|
|
@@ -1058,6 +1077,57 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
|
|
|
1058
1077
|
continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), delay);
|
|
1059
1078
|
}
|
|
1060
1079
|
|
|
1080
|
+
export interface TerminalCompletionNotice {
|
|
1081
|
+
goalId: string;
|
|
1082
|
+
outcome: string;
|
|
1083
|
+
details: string[];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
|
|
1087
|
+
* verifier settles asynchronously — the transcript's last word is "the
|
|
1088
|
+
* verdict will be applied asynchronously", and a toast is the only
|
|
1089
|
+
* closure. A later "how are we looking" then truthfully re-reports the
|
|
1090
|
+
* stale transcript as still-waiting even though the goal is archived.
|
|
1091
|
+
* This delivers the `✓ done` brief INTO the conversation as a followUp
|
|
1092
|
+
* turn so the transcript records the completion. Goal-null-safe (the goal
|
|
1093
|
+
* is already archived when this fires), fire-once per goal via a durable
|
|
1094
|
+
* ledger fence, and fenced like every other automatic send. Returns true
|
|
1095
|
+
* when the notice was dispatched. */
|
|
1096
|
+
export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: TerminalCompletionNotice): boolean {
|
|
1097
|
+
if (supervisorPaused(state)) return false;
|
|
1098
|
+
if (mainModelRecoveryActive()) return false;
|
|
1099
|
+
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
|
|
1100
|
+
if (!flags.extensionApi) return false;
|
|
1101
|
+
if (isForeignCtx(ctx)) return false;
|
|
1102
|
+
try {
|
|
1103
|
+
const already = readLedgerTail(ctx.cwd, 400, (entry) =>
|
|
1104
|
+
entry.type === "terminal_completion_notice_sent" &&
|
|
1105
|
+
typeof (entry.value as { goalId?: unknown } | null)?.goalId === "string" &&
|
|
1106
|
+
(entry.value as { goalId: string }).goalId === notice.goalId,
|
|
1107
|
+
);
|
|
1108
|
+
if (already.length > 0) return false;
|
|
1109
|
+
} catch {
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
const content = [
|
|
1113
|
+
`✓ done — ${notice.outcome}`,
|
|
1114
|
+
...notice.details,
|
|
1115
|
+
"— goal archived; nothing further is owed. Acknowledge briefly; start follow-up work only if asked.",
|
|
1116
|
+
].join("\n");
|
|
1117
|
+
try {
|
|
1118
|
+
flags.extensionApi.sendMessage({
|
|
1119
|
+
customType: GOAL_EVENT_ENTRY,
|
|
1120
|
+
content,
|
|
1121
|
+
display: false,
|
|
1122
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1123
|
+
} catch {
|
|
1124
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_unsent", { goalId: notice.goalId });
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_sent", { goalId: notice.goalId, payloadChars: content.length });
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1061
1131
|
export function buildMarkerContent(goalId: string): string {
|
|
1062
1132
|
return `[GOAL CHECKPOINT goalId=${goalId}]`;
|
|
1063
1133
|
}
|
|
@@ -1504,6 +1574,11 @@ export function clearQueueStuckProbe(): void {
|
|
|
1504
1574
|
export function setContinuationRearmStreak(v: number): void {
|
|
1505
1575
|
continuationRearmStreak = v;
|
|
1506
1576
|
}
|
|
1577
|
+
|
|
1578
|
+
/** Test-only: pretend a turn begin-marker was observed at the given clock. */
|
|
1579
|
+
export function __testOnlySetObservedTurnStartAt(v: number): void {
|
|
1580
|
+
lastObservedTurnStartAt = v;
|
|
1581
|
+
}
|
|
1507
1582
|
export function setContinuationRearmSince(v: number): void {
|
|
1508
1583
|
continuationRearmSince = v;
|
|
1509
1584
|
}
|
|
@@ -1521,6 +1596,7 @@ export function resetContinuationDispatchState(cwd: string): boolean {
|
|
|
1521
1596
|
continuationRearmSince = 0;
|
|
1522
1597
|
continuationRearmMilestone = 0;
|
|
1523
1598
|
lastNoTurnStartedNotifiedAt = 0;
|
|
1599
|
+
lastObservedTurnStartAt = 0;
|
|
1524
1600
|
continuationStartCompactionRearms.clear();
|
|
1525
1601
|
continuationStartRecoveryRearms.clear();
|
|
1526
1602
|
return clearDispatchRecord(cwd);
|
|
@@ -277,7 +277,15 @@ export function zombieRetryDecision(
|
|
|
277
277
|
key: string,
|
|
278
278
|
prev: ZombieRetryStreak,
|
|
279
279
|
maxAttempts = DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS,
|
|
280
|
-
|
|
280
|
+
// v0.38.18 (track 2: neonbreak never-streamed hot loop): the stream clock
|
|
281
|
+
// observed when the aborted turn STARTED. When the abort's observation
|
|
282
|
+
// point never advanced past it, the turn produced zero stream activity in
|
|
283
|
+
// its whole life — an immediate retry replays the same silence (field:
|
|
284
|
+
// 3 abort cycles in 5m, same frozen clock, 23k full payload each). Refuse
|
|
285
|
+
// at once and park for an explicit resume instead of burning the budget.
|
|
286
|
+
// 0/unknown preserves the legacy behavior (retry within budget).
|
|
287
|
+
turnStartAt = 0,
|
|
288
|
+
): { retry: boolean; streak: ZombieRetryStreak; neverStreamed?: boolean } {
|
|
281
289
|
const limit = Number.isInteger(maxAttempts) && maxAttempts >= 0
|
|
282
290
|
? Math.min(maxAttempts, MAX_ZOMBIE_RETRY_ATTEMPTS)
|
|
283
291
|
: DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
|
|
@@ -287,6 +295,9 @@ export function zombieRetryDecision(
|
|
|
287
295
|
const streak: ZombieRetryStreak = sameEpisode
|
|
288
296
|
? { key, count: Math.min(prev.count + 1, MAX_ZOMBIE_RETRY_ATTEMPTS + 1), lastAbortStreamAt: observedStreamAt }
|
|
289
297
|
: { key, count: 1, lastAbortStreamAt: observedStreamAt };
|
|
298
|
+
if (turnStartAt > 0 && observedStreamAt <= turnStartAt) {
|
|
299
|
+
return { retry: false, streak, neverStreamed: true };
|
|
300
|
+
}
|
|
290
301
|
return { retry: streak.count <= limit, streak };
|
|
291
302
|
}
|
|
292
303
|
|
|
@@ -246,6 +246,168 @@ function safeFileMarkerCompound(command: string): string[] | null {
|
|
|
246
246
|
return [`test -${flag} ${testPath}`, `grep -q ${marker} ${grepPath}`];
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* v0.38.18 (track 1: endless-td pipe-syntax wedge): a narrow pipeline form.
|
|
251
|
+
* Contracts legitimately truncate noisy suites (`bun test 2>&1 | tail -n 4`);
|
|
252
|
+
* rejecting the `|` deterministically fast-failed finished work with no
|
|
253
|
+
* agent-side remedy. A pipeline is accepted only when EVERY segment is
|
|
254
|
+
* independently safe: the head is an ordinary safe command (resolved through
|
|
255
|
+
* the canonical runner exactly like a bare command) and each tail segment is
|
|
256
|
+
* an allowlisted text filter. Execution never touches a shell — the head
|
|
257
|
+
* runs through runMechanicalCommand and each filter is spawned with piped
|
|
258
|
+
* stdio. The pipeline passes iff the HEAD exits 0 (pipefail-head semantics:
|
|
259
|
+
* `tail` exits 0 even when the suite is red, so the tail's status is
|
|
260
|
+
* meaningless); filters only truncate evidence. `2>&1` on the head is
|
|
261
|
+
* accepted and ignored — head output already merges stderr. Every other
|
|
262
|
+
* redirect, substitution, quote, or second program stays exit-126 rejected.
|
|
263
|
+
*/
|
|
264
|
+
const MECHANICAL_HEAD_REDIRECT = /\s*2>&1\s*$/;
|
|
265
|
+
const MECHANICAL_TAIL_FILTER = /^(?:tail|head)\s+-[nc]\s+\d{1,6}$/;
|
|
266
|
+
const MECHANICAL_GREP_FILTER = /^grep\s+(?:-[a-z]+\s+)?[A-Za-z0-9_./:@=+,%-]+$/;
|
|
267
|
+
|
|
268
|
+
function isAllowlistedMechanicalFilter(segment: string): boolean {
|
|
269
|
+
return MECHANICAL_TAIL_FILTER.test(segment) || MECHANICAL_GREP_FILTER.test(segment);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface MechanicalPipeline {
|
|
273
|
+
head: string;
|
|
274
|
+
filters: string[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Split a `head | filter | ...` command, or return null when any segment
|
|
278
|
+
* is outside the narrow allowed shape (callers fall through to the 126
|
|
279
|
+
* unsafe rejection). Naive `|` splitting is safe here: quotes and
|
|
280
|
+
* escapes are rejected characters, so a `|` is always a real pipe. */
|
|
281
|
+
export function parseMechanicalPipeline(command: string): MechanicalPipeline | null {
|
|
282
|
+
if (!command.includes("|")) return null;
|
|
283
|
+
const segments = command.split("|").map((s) => s.trim());
|
|
284
|
+
if (segments.length < 2 || segments.some((s) => !s)) return null;
|
|
285
|
+
let head = segments[0]!.replace(MECHANICAL_HEAD_REDIRECT, "").trim();
|
|
286
|
+
if (!head) return null;
|
|
287
|
+
if (!isSafeMechanicalCommand(head)) return null;
|
|
288
|
+
const filters = segments.slice(1);
|
|
289
|
+
if (!filters.every(isAllowlistedMechanicalFilter)) return null;
|
|
290
|
+
return { head, filters };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const MECHANICAL_FILTER_STAGE_TIMEOUT_MS = 30_000;
|
|
294
|
+
|
|
295
|
+
function runMechanicalFilterStage(
|
|
296
|
+
input: string,
|
|
297
|
+
filter: string,
|
|
298
|
+
timeoutMs: number,
|
|
299
|
+
): Promise<{ output: string; exitCode: number; timedOut: boolean; launchError?: string }> {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
const [program = "", ...args] = filter.split(/[ \t]+/);
|
|
302
|
+
let child: ChildProcess;
|
|
303
|
+
try {
|
|
304
|
+
child = spawn(program, args, {
|
|
305
|
+
detached: process.platform !== "win32",
|
|
306
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
resolve({ output: "", exitCode: 1, timedOut: false, launchError: error instanceof Error ? error.message : String(error) });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
let output = "";
|
|
313
|
+
let settled = false;
|
|
314
|
+
const timer = setTimeout(() => {
|
|
315
|
+
if (settled) return;
|
|
316
|
+
settled = true;
|
|
317
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
318
|
+
resolve({ output: output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS), exitCode: 1, timedOut: true });
|
|
319
|
+
}, timeoutMs);
|
|
320
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
321
|
+
output += chunk.toString("utf8");
|
|
322
|
+
if (output.length > MECHANICAL_OUTPUT_TAIL_CHARS * 2) {
|
|
323
|
+
output = output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
child.stderr?.on("data", () => { /* filter diagnostics never enter evidence */ });
|
|
327
|
+
child.on("error", (error) => {
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
resolve({ output: "", exitCode: 1, timedOut: false, launchError: error.message });
|
|
332
|
+
});
|
|
333
|
+
child.on("close", (code) => {
|
|
334
|
+
if (settled) return;
|
|
335
|
+
settled = true;
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
resolve({ output: output.slice(-MECHANICAL_OUTPUT_TAIL_CHARS), exitCode: code ?? 1, timedOut: false });
|
|
338
|
+
});
|
|
339
|
+
try {
|
|
340
|
+
child.stdin?.write(input);
|
|
341
|
+
child.stdin?.end();
|
|
342
|
+
} catch { /* stdin already closed */ }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function runMechanicalPipeline(
|
|
347
|
+
cwd: string,
|
|
348
|
+
pipeline: MechanicalPipeline,
|
|
349
|
+
rawCommand: string,
|
|
350
|
+
scripts: Record<string, string>,
|
|
351
|
+
effectiveTimeoutMs: number,
|
|
352
|
+
signal: AbortSignal | undefined,
|
|
353
|
+
effectiveProcessGroupSize: number,
|
|
354
|
+
): Promise<MechanicalCheckResult> {
|
|
355
|
+
if (process.platform === "win32") {
|
|
356
|
+
return {
|
|
357
|
+
passed: false,
|
|
358
|
+
failedCommand: rawCommand,
|
|
359
|
+
output: "Mechanical pipelines need POSIX coreutils (tail/head/grep) and are not supported on Windows; reword the contract line to a bare runner invocation.",
|
|
360
|
+
exitCode: 126,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const { program, args } = resolveCanonicalRunnerCommand(pipeline.head, scripts);
|
|
364
|
+
if (!program) {
|
|
365
|
+
return { passed: false, failedCommand: rawCommand, output: "Empty mechanical command.", exitCode: 126 };
|
|
366
|
+
}
|
|
367
|
+
// ONE bounded automatic retry for the whole pipeline on a plain head
|
|
368
|
+
// failure (mirrors the single-command v0.35.20 retry); containment
|
|
369
|
+
// events fail immediately without a second untrusted process tree.
|
|
370
|
+
let firstFailure: { output: string; exitCode: number } | null = null;
|
|
371
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
372
|
+
const head = await runMechanicalCommand(cwd, program, args, effectiveTimeoutMs, signal, effectiveProcessGroupSize);
|
|
373
|
+
if (head.timedOut || head.aborted || head.outputLimit || head.processGroupLimit) {
|
|
374
|
+
return { passed: false, failedCommand: rawCommand, ...formatMechanicalFailure(head, effectiveTimeoutMs) };
|
|
375
|
+
}
|
|
376
|
+
let stageInput = head.output;
|
|
377
|
+
let stageFailed: { output: string; exitCode: number } | null = null;
|
|
378
|
+
for (const filter of pipeline.filters) {
|
|
379
|
+
const stage = await runMechanicalFilterStage(stageInput, filter, MECHANICAL_FILTER_STAGE_TIMEOUT_MS);
|
|
380
|
+
if (stage.timedOut || stage.launchError) {
|
|
381
|
+
stageFailed = {
|
|
382
|
+
output: `[pipeline filter '${filter}' did not complete: ${stage.timedOut ? "timed out" : stage.launchError}]`,
|
|
383
|
+
exitCode: 1,
|
|
384
|
+
};
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
stageInput = stage.output;
|
|
388
|
+
}
|
|
389
|
+
if (stageFailed) {
|
|
390
|
+
return { passed: false, failedCommand: rawCommand, ...stageFailed };
|
|
391
|
+
}
|
|
392
|
+
if (head.exitCode === 0) {
|
|
393
|
+
return { passed: true };
|
|
394
|
+
}
|
|
395
|
+
const evidence = [`[pipeline head exited ${head.exitCode}]`, stageInput || "(no output survived the filters)"]
|
|
396
|
+
.join("\n")
|
|
397
|
+
.slice(-MECHANICAL_OUTPUT_TAIL_CHARS);
|
|
398
|
+
if (attempt === 1) {
|
|
399
|
+
firstFailure = { output: evidence, exitCode: head.exitCode };
|
|
400
|
+
} else {
|
|
401
|
+
return {
|
|
402
|
+
passed: false,
|
|
403
|
+
failedCommand: rawCommand,
|
|
404
|
+
output: `[mechanical pipeline retried once after a failed first attempt (head exit ${firstFailure!.exitCode}); second attempt also failed — output tail below]\n` + evidence,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { passed: false, failedCommand: rawCommand, output: firstFailure?.output ?? "", exitCode: firstFailure?.exitCode ?? 1 };
|
|
409
|
+
}
|
|
410
|
+
|
|
249
411
|
const DEFAULT_MECHANICAL_CHECK_TIMEOUT_MS = 600_000;
|
|
250
412
|
const MECHANICAL_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
251
413
|
const MECHANICAL_OUTPUT_TAIL_CHARS = 64 * 1024;
|
|
@@ -590,6 +752,11 @@ export async function runMechanicalPreAuditChecks(
|
|
|
590
752
|
: DEFAULT_MECHANICAL_CHECK_TIMEOUT_MS;
|
|
591
753
|
const effectiveProcessGroupSize = normalizeMechanicalProcessGroupLimit(maxProcessGroupSize);
|
|
592
754
|
const recoveredRetries: string[] = [];
|
|
755
|
+
let scripts: Record<string, string> = {};
|
|
756
|
+
try {
|
|
757
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
|
|
758
|
+
if (pkg && typeof pkg.scripts === "object" && pkg.scripts) scripts = pkg.scripts;
|
|
759
|
+
} catch { /* no package.json — nothing to resolve */ }
|
|
593
760
|
for (const rawCommand of commands) {
|
|
594
761
|
const cmd = rawCommand.trim();
|
|
595
762
|
const compound = safeFileMarkerCompound(cmd);
|
|
@@ -603,11 +770,21 @@ export async function runMechanicalPreAuditChecks(
|
|
|
603
770
|
}
|
|
604
771
|
continue;
|
|
605
772
|
}
|
|
773
|
+
// v0.38.18 (track 1): narrow `head | tail/head/grep` pipelines run
|
|
774
|
+
// shell-free before the single-command gate below.
|
|
775
|
+
const pipeline = parseMechanicalPipeline(cmd);
|
|
776
|
+
if (pipeline) {
|
|
777
|
+
const pipelineResult = await runMechanicalPipeline(cwd, pipeline, rawCommand, scripts, effectiveTimeoutMs, signal, effectiveProcessGroupSize);
|
|
778
|
+
if (!pipelineResult.passed) return pipelineResult;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
606
781
|
if (!isSafeMechanicalCommand(cmd)) {
|
|
607
782
|
return {
|
|
608
783
|
passed: false,
|
|
609
784
|
failedCommand: rawCommand,
|
|
610
|
-
output:
|
|
785
|
+
output: cmd.includes("|")
|
|
786
|
+
? "Rejected unsafe mechanical pipeline syntax; only `command | tail -n N`, `command | head -n N`, and `command | grep <literal>` are allowed (no redirects except a trailing 2>&1, no second program)."
|
|
787
|
+
: "Rejected unsafe mechanical command syntax; only a single executable followed by literal arguments is allowed.",
|
|
611
788
|
exitCode: 126,
|
|
612
789
|
};
|
|
613
790
|
}
|
|
@@ -617,11 +794,7 @@ export async function runMechanicalPreAuditChecks(
|
|
|
617
794
|
// flags the suite requires (serialization/isolation/timeouts). Running
|
|
618
795
|
// the bare runner ignores that configuration and fails spuriously while
|
|
619
796
|
// the real gate is green (field: fourth audit round, 2026-08-21).
|
|
620
|
-
|
|
621
|
-
try {
|
|
622
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
|
|
623
|
-
if (pkg && typeof pkg.scripts === "object" && pkg.scripts) scripts = pkg.scripts;
|
|
624
|
-
} catch { /* no package.json — nothing to resolve */ }
|
|
797
|
+
// (scripts are loaded once above the loop so the pipeline path shares them.)
|
|
625
798
|
const { program, args } = resolveCanonicalRunnerCommand(cmd, scripts);
|
|
626
799
|
if (!program) {
|
|
627
800
|
return { passed: false, failedCommand: rawCommand, output: "Empty mechanical command.", exitCode: 126 };
|
|
@@ -460,6 +460,13 @@ const ZOMBIE_PAUSE_REASON = "automatic zero-stream abort — no provider activit
|
|
|
460
460
|
// the park standing for manual resume — an honest degradation.
|
|
461
461
|
let zombieRetryStreak: ZombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
|
|
462
462
|
let zombieRetryTimer: NodeJS.Timeout | null = null;
|
|
463
|
+
// v0.38.18 (track 2): the stream clock when the current turn's begin-marker
|
|
464
|
+
// was last observed. Compared against the abort's observation point to tell
|
|
465
|
+
// "wedged after streaming" (deserves budget retries) from "never streamed
|
|
466
|
+
// since birth" (an immediate retry replays the silence — park at once).
|
|
467
|
+
// 0 means no begin-marker observed (extension loaded mid-turn): unknown, and
|
|
468
|
+
// unknown preserves the legacy retry-within-budget behavior.
|
|
469
|
+
let lastTurnStartAt = 0;
|
|
463
470
|
// An in-band provider pane is observed during tool_result and consumed at the
|
|
464
471
|
// matching agent_end. Keep the raw text only in memory; the durable ledger
|
|
465
472
|
// records the bounded classification, never the provider payload.
|
|
@@ -477,15 +484,15 @@ function scheduleZombieAutoRetry(
|
|
|
477
484
|
goalId: string | undefined,
|
|
478
485
|
observedStreamAt: number,
|
|
479
486
|
silentMinutes: number,
|
|
480
|
-
): { scheduled: boolean; attempt: number; maxAttempts: number } {
|
|
487
|
+
): { scheduled: boolean; attempt: number; maxAttempts: number; reason?: "never-streamed" | "budget-exhausted" } {
|
|
481
488
|
const key = goalId ?? "loop";
|
|
482
489
|
const maxAttempts = zombieRetryMaxAttemptsOverride
|
|
483
490
|
?? loadSettings(ctx.cwd).zombieRetryMaxAttempts
|
|
484
491
|
?? DEFAULT_ZOMBIE_RETRY_MAX_ATTEMPTS;
|
|
485
|
-
const { retry, streak } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak, maxAttempts);
|
|
492
|
+
const { retry, streak, neverStreamed } = zombieRetryDecision(observedStreamAt, key, zombieRetryStreak, maxAttempts, lastTurnStartAt);
|
|
486
493
|
zombieRetryStreak = streak;
|
|
487
|
-
const plan = { scheduled: retry, attempt: streak.count, maxAttempts };
|
|
488
|
-
appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : "zombie_auto_retry_refused_streak", {
|
|
494
|
+
const plan = { scheduled: retry, attempt: streak.count, maxAttempts, reason: neverStreamed ? "never-streamed" as const : retry ? undefined : "budget-exhausted" as const };
|
|
495
|
+
appendLedger(ctx.cwd, retry ? "zombie_auto_retry_scheduled" : neverStreamed ? "zombie_auto_retry_refused_never_streamed" : "zombie_auto_retry_refused_streak", {
|
|
489
496
|
goalId,
|
|
490
497
|
streakCount: streak.count,
|
|
491
498
|
maxAttempts,
|
|
@@ -566,6 +573,12 @@ export function __testOnlyResetZombieAutoRetry(): void {
|
|
|
566
573
|
}
|
|
567
574
|
zombieRetryStreak = { key: "", count: 0, lastAbortStreamAt: 0 };
|
|
568
575
|
zombieRetryMaxAttemptsOverride = null;
|
|
576
|
+
lastTurnStartAt = 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Test-only: pretend a turn begin-marker was observed at the given clock. */
|
|
580
|
+
export function __testOnlySetTurnStartAt(at: number): void {
|
|
581
|
+
lastTurnStartAt = at;
|
|
569
582
|
}
|
|
570
583
|
|
|
571
584
|
let zombieRetryDelayOverride: number | null = null;
|
|
@@ -621,7 +634,7 @@ export function abortZombieRun(
|
|
|
621
634
|
status: "paused",
|
|
622
635
|
pauseKind: "error",
|
|
623
636
|
pauseReason: reason,
|
|
624
|
-
pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. Automatic retries use the configured zero-stream budget; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it
|
|
637
|
+
pauseSuggestedAction: `The stalled ${noun} turn was aborted safely and the work is saved. ${lastTurnStartAt > 0 && observedStreamAt <= lastTurnStartAt ? `The turn never produced stream activity, so no automatic retry was scheduled; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.` : `Automatic retries use the configured zero-stream budget; ${activeGoalSurfaceCommand("resume")} retries immediately and ${activeGoalSurfaceCommand("cancel")} discards it.`}`,
|
|
625
638
|
}, current);
|
|
626
639
|
} else if (loop) {
|
|
627
640
|
clearLoopTimer();
|
|
@@ -649,6 +662,9 @@ export function abortZombieRun(
|
|
|
649
662
|
// v0.35.x: keep the park as a bounded waystation for repeated
|
|
650
663
|
// zero-stream attempts. Once the configured budget is exhausted the
|
|
651
664
|
// refusal remains manual and durable.
|
|
665
|
+
// v0.38.18 (track 2): a turn that never streamed gets no hot retry — say
|
|
666
|
+
// so in the park copy instead of promising budget retries that never come.
|
|
667
|
+
const neverStreamed = lastTurnStartAt > 0 && observedStreamAt <= lastTurnStartAt;
|
|
652
668
|
const retryPlan = scheduleZombieAutoRetry(current, generation, goal?.id, observedStreamAt, silentMinutes);
|
|
653
669
|
const loopRecap = loop
|
|
654
670
|
? compactLoopCompletionSummary({ ...loop, historyLength: loop.history.length })
|
|
@@ -656,7 +672,9 @@ export function abortZombieRun(
|
|
|
656
672
|
const message = `${goal ? goal.policy === "list" ? "List item" : "Goal" : "Loop"} paused after ${silentMinutes}m with zero stream activity; the zombie turn was aborted and queued retries were stopped. Work is saved. ${
|
|
657
673
|
retryPlan.scheduled
|
|
658
674
|
? `Automatic retry ${retryPlan.attempt}/${retryPlan.maxAttempts} starts in ~90s; ${resume} retries immediately instead.`
|
|
659
|
-
:
|
|
675
|
+
: retryPlan.reason === "never-streamed"
|
|
676
|
+
? `The turn never produced stream activity, so no automatic retry was scheduled — a retry would replay the same silence. ${resume} retries it, or ${cancel} discards/stops it.`
|
|
677
|
+
: `${resume} retries it, or ${cancel} discards/stops it; the automatic retry budget is exhausted.`
|
|
660
678
|
}${loopRecap ? `\nRecap: ${loopRecap}` : ""}`;
|
|
661
679
|
current.ui.notify(message, abortError ? "warning" : "info");
|
|
662
680
|
notifyExternal(current, message);
|
|
@@ -2590,6 +2608,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
|
|
|
2590
2608
|
if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || isForeignCtx(ctx)) return;
|
|
2591
2609
|
ensureAgentToolsReady(ctx, true);
|
|
2592
2610
|
lastStreamActivityAt = Date.now();
|
|
2611
|
+
lastTurnStartAt = lastStreamActivityAt;
|
|
2593
2612
|
streamActivityObserved = true;
|
|
2594
2613
|
// v0.32.1: a real turn started — the post-compaction resume debt is
|
|
2595
2614
|
// discharged (the heartbeat stops retrying it).
|
|
@@ -2605,6 +2624,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
|
|
|
2605
2624
|
if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown || isForeignCtx(ctx)) return;
|
|
2606
2625
|
ensureAgentToolsReady(ctx, true);
|
|
2607
2626
|
lastStreamActivityAt = Date.now();
|
|
2627
|
+
lastTurnStartAt = lastStreamActivityAt;
|
|
2608
2628
|
streamActivityObserved = true;
|
|
2609
2629
|
dispatchStartAcknowledged(ctx, "turn_start");
|
|
2610
2630
|
});
|
|
@@ -165,6 +165,7 @@ import {
|
|
|
165
165
|
setLastContinuationSentPayloadRef,
|
|
166
166
|
setContinuationRearmStreak,
|
|
167
167
|
setContinuationRearmSince,
|
|
168
|
+
sendTerminalCompletionNotice,
|
|
168
169
|
type ContinuationFlags,
|
|
169
170
|
type ContinuationDeps,
|
|
170
171
|
} from "../goal-continuation.js";
|
|
@@ -1443,6 +1444,19 @@ async function retryStoredCompletionAudit(origin: CompletionAuditOrigin = "provi
|
|
|
1443
1444
|
// single line (pager/sound safe).
|
|
1444
1445
|
liveCtx.ui.notify(`✓ done — ${brief.outcome}\n${[...brief.details, `— auditor ${result.model} approved${approvalVia}.`].join("\n")}`, "info");
|
|
1445
1446
|
notifyExternal(liveCtx, `Goal complete (auditor approved, ${origin}): ${recap}`);
|
|
1447
|
+
// v0.38.18 (track 3): the toast above is ephemeral — without a
|
|
1448
|
+
// transcript entry the session keeps narrating "waiting on the
|
|
1449
|
+
// auditor's verdict" after the archive (junk-runner field). Deliver
|
|
1450
|
+
// the brief into the conversation. Skipped for manual /goal verify:
|
|
1451
|
+
// that runs inside a turn whose command output already closes the
|
|
1452
|
+
// transcript. Fire-once fenced inside the sender.
|
|
1453
|
+
if (origin !== "manual") {
|
|
1454
|
+
sendTerminalCompletionNotice(liveCtx, {
|
|
1455
|
+
goalId,
|
|
1456
|
+
outcome: brief.outcome,
|
|
1457
|
+
details: [...brief.details, `— auditor ${result.model} approved${approvalVia}.`],
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1446
1460
|
return;
|
|
1447
1461
|
}
|
|
1448
1462
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.18",
|
|
4
4
|
"description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. A detached extension-less auditor process re-verifies every completion with raw evidence without holding the main pi turn; confirmed drafts, decision pauses and consent gates keep you in charge.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "dracon",
|