pi-goal-list-loop-audit 0.38.17 → 0.38.19
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 +14 -0
- package/docs/INDEX.md +1 -1
- package/extensions/goal-continuation.ts +126 -7
- package/extensions/goal-loop-backoff.ts +12 -1
- package/extensions/goal-loop-display.ts +15 -2
- 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/extensions/loops/goal-ui.ts +6 -0
- package/extensions/loops/goal.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.38.19 — disapproval response: dispatch stall root-cause + state clear (2026-09-04)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Track 2 root cause (post-answer stall): the wait-for-idle send gate deferred an owed continuation forever when the session went phantom-busy — busy, nothing pending, zero stream (neonbreak: 35 re-arms, zero sends, 45m to the zombie abort). A busy session with nothing pending and no real stream for `GLLA_BUSY_SILENT_SEND_MS` (default 5m) is wedged, not working: the marker is now sent into pi's followUp queue instead (`goal_continuation_send_busy_bypass`, `busyBypass` on the sent event). Genuinely working sessions and loaded queues keep the old wait path.
|
|
7
|
+
- Track 3 state clear (stale waiting-verdict): `auditorDisplayPhase` projects `awaiting-verdict` only while the goal is still `auditing`. Closed goals (complete/aborted) and pre-archive snapshots carrying a stale running claim fall through to the quiet gate — no stale progress object can resurrect the wait after `goal_archived`.
|
|
8
|
+
- Both fixes carry behavioral MockPi regression tests with empirical fail-before (old code fails the new assertions, guards pass throughout): `tests/answered-question-dispatch.test.ts` (3), `tests/closed-goal-clears-waiting.test.ts` (2).
|
|
9
|
+
|
|
10
|
+
## 0.38.18 — completion/lifecycle field trilogy (2026-09-04)
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- 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).
|
|
14
|
+
- 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".
|
|
15
|
+
- 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).
|
|
16
|
+
|
|
3
17
|
## 0.38.17 — release-contract docs trail (2026-09-04)
|
|
4
18
|
|
|
5
19
|
### 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.19 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
|
|
@@ -323,6 +330,26 @@ export function sendRearmDelayMs(streak: number): number {
|
|
|
323
330
|
return 30_000;
|
|
324
331
|
}
|
|
325
332
|
|
|
333
|
+
/** v0.38.19 (track 2): how long a busy session with an owed send may go
|
|
334
|
+
* without real stream output before the marker is sent into pi's followUp
|
|
335
|
+
* queue anyway. 5m matches SEND_REARM_ESCALATE_SILENT_MS's definition of
|
|
336
|
+
* wedged silence, far below the ~45m zombie abort that used to be the only
|
|
337
|
+
* exit from a phantom-busy session. */
|
|
338
|
+
export function busySilentSendMs(): number {
|
|
339
|
+
const raw = Number(process.env.GLLA_BUSY_SILENT_SEND_MS ?? 5 * 60_000);
|
|
340
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 5 * 60_000;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** v0.38.19 (track 2): true when the session's business is stale enough
|
|
344
|
+
* that waiting for idle is the stall, not patience. Requires observed
|
|
345
|
+
* stream history (lastRealActivityAt > 0): a session that never streamed
|
|
346
|
+
* in this process is ignorance, not evidence of wedging — it keeps the old
|
|
347
|
+
* wait path (storm → escalation → zombie abort). */
|
|
348
|
+
export function busySilentBypassDue(): boolean {
|
|
349
|
+
if (flags.lastRealActivityAt <= 0) return false;
|
|
350
|
+
return Date.now() - flags.lastRealActivityAt >= busySilentSendMs();
|
|
351
|
+
}
|
|
352
|
+
|
|
326
353
|
export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "loop"): void {
|
|
327
354
|
const streak = kind === "continuation" ? ++continuationRearmStreak : ++flags.loopRearmStreak;
|
|
328
355
|
if (streak === 1) {
|
|
@@ -348,10 +375,18 @@ export function accountSendRearm(ctx: ExtensionContext, kind: "continuation" | "
|
|
|
348
375
|
const noDispatchAccepted = lastContinuationSentAt === 0 || lastContinuationSentAt < continuationRearmSince;
|
|
349
376
|
if (noDispatchAccepted && lastNoTurnStartedNotifiedAt + SEND_REARM_LEDGER_MILESTONES_MS[0]! <= Date.now()) {
|
|
350
377
|
lastNoTurnStartedNotifiedAt = Date.now();
|
|
351
|
-
|
|
378
|
+
// v0.38.18 (track 2): a turn that began after the storm started is
|
|
379
|
+
// open — reporting "no turn started" then is false and sends the
|
|
380
|
+
// user down the wrong path (resume a queue that is not stuck vs
|
|
381
|
+
// interrupt a hung turn). Say which is true.
|
|
382
|
+
const turnOpen = lastObservedTurnStartAt >= since && lastObservedTurnStartAt > 0;
|
|
383
|
+
const turnOpenMinutes = turnOpen ? Math.max(1, Math.round((Date.now() - lastObservedTurnStartAt) / 60000)) : 0;
|
|
384
|
+
appendLedger(ctx.cwd, "rearm_no_turn_started", { streak, minutes: Math.round(elapsed / 60000), turnOpen, turnOpenMinutes });
|
|
352
385
|
const aggressive = resolveEffectiveAggressiveSettings(loadSettings(ctx.cwd)).aggressiveMode;
|
|
353
386
|
const recoveryStop = aggressive ? "a state-based stop" : "the automatic horizon";
|
|
354
|
-
const msg =
|
|
387
|
+
const msg = turnOpen
|
|
388
|
+
? `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}.`
|
|
389
|
+
: `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
390
|
ctx.ui.notify(msg, "warning");
|
|
356
391
|
notifyExternal(ctx, msg);
|
|
357
392
|
}
|
|
@@ -569,6 +604,10 @@ export function dispatchFailed(ctx: ExtensionContext, record: ContinuationDispat
|
|
|
569
604
|
}
|
|
570
605
|
|
|
571
606
|
export function dispatchStartAcknowledged(ctx: ExtensionContext, source: string, prompt?: unknown): boolean {
|
|
607
|
+
// v0.38.18 (track 2): record every begin-marker even when no dispatch is
|
|
608
|
+
// pending — the rearm milestone uses it to tell an open-but-silent turn
|
|
609
|
+
// from a turn that never started.
|
|
610
|
+
if (source === "turn_start" || source === "agent_start") lastObservedTurnStartAt = Date.now();
|
|
572
611
|
// v0.34.104 ([Image-#1]): any real agent activity during the
|
|
573
612
|
// post-list-completion settle window means pi woke up on its own — the
|
|
574
613
|
// deferred continuation must be cancelled so we don't double-dispatch.
|
|
@@ -1058,6 +1097,57 @@ export function scheduleContinuation(ctx: ExtensionContext, force = false, delay
|
|
|
1058
1097
|
continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), delay);
|
|
1059
1098
|
}
|
|
1060
1099
|
|
|
1100
|
+
export interface TerminalCompletionNotice {
|
|
1101
|
+
goalId: string;
|
|
1102
|
+
outcome: string;
|
|
1103
|
+
details: string[];
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** v0.38.18 (track 3: junk-runner stale waiting-verdict): the detached
|
|
1107
|
+
* verifier settles asynchronously — the transcript's last word is "the
|
|
1108
|
+
* verdict will be applied asynchronously", and a toast is the only
|
|
1109
|
+
* closure. A later "how are we looking" then truthfully re-reports the
|
|
1110
|
+
* stale transcript as still-waiting even though the goal is archived.
|
|
1111
|
+
* This delivers the `✓ done` brief INTO the conversation as a followUp
|
|
1112
|
+
* turn so the transcript records the completion. Goal-null-safe (the goal
|
|
1113
|
+
* is already archived when this fires), fire-once per goal via a durable
|
|
1114
|
+
* ledger fence, and fenced like every other automatic send. Returns true
|
|
1115
|
+
* when the notice was dispatched. */
|
|
1116
|
+
export function sendTerminalCompletionNotice(ctx: ExtensionContext, notice: TerminalCompletionNotice): boolean {
|
|
1117
|
+
if (supervisorPaused(state)) return false;
|
|
1118
|
+
if (mainModelRecoveryActive()) return false;
|
|
1119
|
+
if (flags.sessionHandoffPending || flags.initialSessionLoadPending || flags.extensionApiStale || flags.staleTerminalDone || flags.zombieStoodDown) return false;
|
|
1120
|
+
if (!flags.extensionApi) return false;
|
|
1121
|
+
if (isForeignCtx(ctx)) return false;
|
|
1122
|
+
try {
|
|
1123
|
+
const already = readLedgerTail(ctx.cwd, 400, (entry) =>
|
|
1124
|
+
entry.type === "terminal_completion_notice_sent" &&
|
|
1125
|
+
typeof (entry.value as { goalId?: unknown } | null)?.goalId === "string" &&
|
|
1126
|
+
(entry.value as { goalId: string }).goalId === notice.goalId,
|
|
1127
|
+
);
|
|
1128
|
+
if (already.length > 0) return false;
|
|
1129
|
+
} catch {
|
|
1130
|
+
return false;
|
|
1131
|
+
}
|
|
1132
|
+
const content = [
|
|
1133
|
+
`✓ done — ${notice.outcome}`,
|
|
1134
|
+
...notice.details,
|
|
1135
|
+
"— goal archived; nothing further is owed. Acknowledge briefly; start follow-up work only if asked.",
|
|
1136
|
+
].join("\n");
|
|
1137
|
+
try {
|
|
1138
|
+
flags.extensionApi.sendMessage({
|
|
1139
|
+
customType: GOAL_EVENT_ENTRY,
|
|
1140
|
+
content,
|
|
1141
|
+
display: false,
|
|
1142
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1143
|
+
} catch {
|
|
1144
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_unsent", { goalId: notice.goalId });
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
appendLedger(ctx.cwd, "terminal_completion_notice_sent", { goalId: notice.goalId, payloadChars: content.length });
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1061
1151
|
export function buildMarkerContent(goalId: string): string {
|
|
1062
1152
|
return `[GOAL CHECKPOINT goalId=${goalId}]`;
|
|
1063
1153
|
}
|
|
@@ -1155,12 +1245,35 @@ export function sendContinuation(goalId: string): void {
|
|
|
1155
1245
|
}
|
|
1156
1246
|
if (!guardGoalBeforeContinuation(ctx, "dispatch", goalId)) return;
|
|
1157
1247
|
if (!isActionableGoal()) return;
|
|
1248
|
+
// v0.38.19 (track 2): set when the busy-but-silent bypass below fires so
|
|
1249
|
+
// the sent ledger event carries the marker. The test hook for the stream
|
|
1250
|
+
// clock lives in loops/goal-ui.ts (__testOnlySetLastRealActivityAt).
|
|
1251
|
+
let busyBypass = false;
|
|
1158
1252
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
1159
1253
|
accountSendRearm(ctx, "continuation");
|
|
1160
|
-
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1254
|
+
// v0.38.19 (track 2, auditor-required): busy-but-silent bypass. A
|
|
1255
|
+
// session that is busy with nothing pending and zero real stream for
|
|
1256
|
+
// busySilentSendMs is not doing work a followUp could corrupt — it is
|
|
1257
|
+
// wedged (neonbreak: answer in, 45m of phantom-busy, zero sends because
|
|
1258
|
+
// wait-for-idle never cleared). pi's followUp queue is the designed
|
|
1259
|
+
// vehicle for exactly this: the marker queues behind the stuck turn
|
|
1260
|
+
// instead of rearming into the void until the zombie abort. Genuinely
|
|
1261
|
+
// working sessions (fresh stream) and loaded queues (pending messages)
|
|
1262
|
+
// keep the old wait path.
|
|
1263
|
+
if (!ctx.isIdle() && !ctx.hasPendingMessages() && busySilentBypassDue()) {
|
|
1264
|
+
busyBypass = true;
|
|
1265
|
+
appendLedger(ctx.cwd, "goal_continuation_send_busy_bypass", {
|
|
1266
|
+
goalId,
|
|
1267
|
+
generation: flags.sessionGeneration,
|
|
1268
|
+
silentMs: Date.now() - flags.lastRealActivityAt,
|
|
1269
|
+
rearmStreak: continuationRearmStreak,
|
|
1270
|
+
});
|
|
1271
|
+
} else {
|
|
1272
|
+
continuationScheduledFor = goalId;
|
|
1273
|
+
// v0.28.29: backing-off cadence (was flat 50ms — 6,000 spins in 5m).
|
|
1274
|
+
continuationTimer = scheduleSessionTimeout(() => sendContinuation(goalId), sendRearmDelayMs(continuationRearmStreak));
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1164
1277
|
}
|
|
1165
1278
|
if (!flags.extensionApi || flags.extensionApiStale) return;
|
|
1166
1279
|
try {
|
|
@@ -1194,7 +1307,7 @@ export function sendContinuation(goalId: string): void {
|
|
|
1194
1307
|
lastContinuationSentPayload = { content, display: false }; // v0.34.88: verbatim retry payload
|
|
1195
1308
|
if (!dispatchAccepted(ctx, attempt)) return;
|
|
1196
1309
|
continuationRearmStreak = 0; continuationRearmSince = 0; // v0.28.5 (E3): an accepted dispatch clears the storm
|
|
1197
|
-
appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length });
|
|
1310
|
+
appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length, ...(busyBypass ? { busyBypass: true } : {}) });
|
|
1198
1311
|
// v0.35.37 (audit finding): the welcome-back recovery notice must fire
|
|
1199
1312
|
// EXACTLY ONCE per auto-resume. The payload above was built with the
|
|
1200
1313
|
// notice in it; once the dispatch is ACCEPTED the message has landed in
|
|
@@ -1504,6 +1617,11 @@ export function clearQueueStuckProbe(): void {
|
|
|
1504
1617
|
export function setContinuationRearmStreak(v: number): void {
|
|
1505
1618
|
continuationRearmStreak = v;
|
|
1506
1619
|
}
|
|
1620
|
+
|
|
1621
|
+
/** Test-only: pretend a turn begin-marker was observed at the given clock. */
|
|
1622
|
+
export function __testOnlySetObservedTurnStartAt(v: number): void {
|
|
1623
|
+
lastObservedTurnStartAt = v;
|
|
1624
|
+
}
|
|
1507
1625
|
export function setContinuationRearmSince(v: number): void {
|
|
1508
1626
|
continuationRearmSince = v;
|
|
1509
1627
|
}
|
|
@@ -1521,6 +1639,7 @@ export function resetContinuationDispatchState(cwd: string): boolean {
|
|
|
1521
1639
|
continuationRearmSince = 0;
|
|
1522
1640
|
continuationRearmMilestone = 0;
|
|
1523
1641
|
lastNoTurnStartedNotifiedAt = 0;
|
|
1642
|
+
lastObservedTurnStartAt = 0;
|
|
1524
1643
|
continuationStartCompactionRearms.clear();
|
|
1525
1644
|
continuationStartRecoveryRearms.clear();
|
|
1526
1645
|
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
|
|
|
@@ -668,7 +668,15 @@ export function auditorDisplayPhase(g: Goal, audit: AuditDisplayProgress | null
|
|
|
668
668
|
const label = audit?.label?.toLowerCase() ?? "";
|
|
669
669
|
if (label === "queued") return "queued";
|
|
670
670
|
if (/infra|error|failed|blocked|no verdict/.test(label)) return "blocked";
|
|
671
|
-
|
|
671
|
+
// v0.38.19 (track 3, auditor-required): awaiting-verdict is a LIVE claim
|
|
672
|
+
// — a worker done, its verdict not yet applied. A closed goal (complete /
|
|
673
|
+
// aborted, or anything past auditing) can never be waiting: the verdict
|
|
674
|
+
// landed or the claim died with the archive. A stale progress object
|
|
675
|
+
// handed in for a closed goal must not resurrect the wait (junk-runner:
|
|
676
|
+
// session narrating "waiting on the auditor's verdict" after
|
|
677
|
+
// goal_archived complete). Stale timestamps fall through to the quiet
|
|
678
|
+
// gate below, which is true — no worker will ever speak again.
|
|
679
|
+
if (audit?.phase === "complete" && g.status === "auditing") return "awaiting-verdict";
|
|
672
680
|
const age = auditorActivityAge(audit, now);
|
|
673
681
|
if (age !== undefined && age > AUDITOR_QUIET_MS) {
|
|
674
682
|
// v0.37.0: progress-aware quiet gate. A tool that is still running INSIDE
|
|
@@ -691,7 +699,12 @@ export function auditorDisplayPhase(g: Goal, audit: AuditDisplayProgress | null
|
|
|
691
699
|
return "running";
|
|
692
700
|
return "quiet";
|
|
693
701
|
}
|
|
694
|
-
|
|
702
|
+
// Same lifecycle scope as above: a stale goal snapshot that still
|
|
703
|
+
// carries a running pendingCompletion must not project a wait either.
|
|
704
|
+
// (The archive strips pendingCompletion; this guards pre-archive
|
|
705
|
+
// snapshots read after the close.)
|
|
706
|
+
if (!audit && g.status === "auditing" && g.pendingCompletion?.phase === "running") return "awaiting-verdict";
|
|
707
|
+
if (!audit && g.pendingCompletion?.phase === "running") return "quiet";
|
|
695
708
|
return "running";
|
|
696
709
|
}
|
|
697
710
|
|
|
@@ -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
|
|
|
@@ -1044,6 +1044,12 @@ function isContextStarvedRefused(): boolean {
|
|
|
1044
1044
|
export function __testOnlySetLastCompactionAt(at: number | null): void {
|
|
1045
1045
|
lastCompactionAt = at ?? 0;
|
|
1046
1046
|
}
|
|
1047
|
+
/** Test-only: set the real-stream clock directly so busy-silence scenarios
|
|
1048
|
+
* (answered question, phantom-busy host, zero stream) are drivable without
|
|
1049
|
+
* wall-clock waits. Exported through loops/goal.js like the other hooks. */
|
|
1050
|
+
export function __testOnlySetLastRealActivityAt(at: number): void {
|
|
1051
|
+
lastRealActivityAt = at;
|
|
1052
|
+
}
|
|
1047
1053
|
|
|
1048
1054
|
/** Test-only: expose the live activity projection without requiring a TUI
|
|
1049
1055
|
* renderer. This drives the same scope/timestamp checks used by refreshUI. */
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -74,7 +74,7 @@ export {
|
|
|
74
74
|
__testOnlyResetOwnerSession,
|
|
75
75
|
__testOnlyRunFanOutListAuditFindings,
|
|
76
76
|
} from "./goal-session.js";
|
|
77
|
-
export { __testOnlySetLastCompactionAt, __testOnlyLoadState, __testOnlyRegisterAgentTools, __testOnlyRememberCtx, __testOnlyDisplayActivityFor, __testOnlyResetToolActivity } from "./goal-ui.js";
|
|
77
|
+
export { __testOnlySetLastCompactionAt, __testOnlyLoadState, __testOnlyRegisterAgentTools, __testOnlyRememberCtx, __testOnlyDisplayActivityFor, __testOnlyResetToolActivity, __testOnlySetLastRealActivityAt } from "./goal-ui.js";
|
|
78
78
|
export { auditorRetryPlan, runDetachedCompletionWithFallback, __testOnlySetAuditorRecoveryRetryDelay, __testOnlyResetAuditorRecoveryRuntime, type AuditorModelCandidate } from "./goal-auditor-hooks.js";
|
|
79
79
|
export { handleSettingChoice } from "./goal-settings-ui.js";
|
|
80
80
|
|
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.19",
|
|
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",
|