taskplane 0.30.4 → 0.30.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/taskplane/agent-bridge-extension.ts +66 -8
- package/extensions/taskplane/agent-host.ts +170 -9
- package/extensions/taskplane/config-loader.ts +9 -0
- package/extensions/taskplane/config-schema.ts +47 -1
- package/extensions/taskplane/context-repair.ts +158 -0
- package/extensions/taskplane/diagnostic-reports.ts +109 -6
- package/extensions/taskplane/diagnostics.ts +3 -0
- package/extensions/taskplane/engine-identity.ts +401 -0
- package/extensions/taskplane/engine-worker.ts +59 -3
- package/extensions/taskplane/engine.ts +137 -17
- package/extensions/taskplane/execution.ts +89 -8
- package/extensions/taskplane/extension.ts +1288 -241
- package/extensions/taskplane/git.ts +74 -0
- package/extensions/taskplane/lane-runner.ts +971 -95
- package/extensions/taskplane/process-registry.ts +7 -2
- package/extensions/taskplane/resume.ts +559 -23
- package/extensions/taskplane/review-analysis.ts +450 -0
- package/extensions/taskplane/segment-recovery.ts +192 -0
- package/extensions/taskplane/supervisor-dispatch.ts +103 -0
- package/extensions/taskplane/supervisor-primer.md +182 -2
- package/extensions/taskplane/supervisor.ts +247 -24
- package/extensions/taskplane/types.ts +142 -4
- package/extensions/taskplane/worktree.ts +143 -2
- package/package.json +1 -1
|
@@ -69,10 +69,191 @@ import {
|
|
|
69
69
|
type SupervisorAlertCallback,
|
|
70
70
|
type StepSegmentMapping,
|
|
71
71
|
type SegmentScopeMode,
|
|
72
|
+
type RuntimeAgentEvent,
|
|
73
|
+
type EngineEvent,
|
|
74
|
+
type EngineEventType,
|
|
75
|
+
type ReviewDisposition,
|
|
76
|
+
type ReviewInterventionKind,
|
|
77
|
+
type SupervisorAlert,
|
|
78
|
+
type PauseSignal,
|
|
72
79
|
} from "./types.ts";
|
|
80
|
+
import type { TaskExitDiagnostic } from "./diagnostics.ts";
|
|
81
|
+
import {
|
|
82
|
+
parseFindingCounts,
|
|
83
|
+
computeFindingTrend,
|
|
84
|
+
parseReviewLabelFromPath,
|
|
85
|
+
advanceReviewStreak,
|
|
86
|
+
reconstructReviewStreaks,
|
|
87
|
+
freshReviewStreakState,
|
|
88
|
+
shouldFireSpiral,
|
|
89
|
+
shouldFireOrderViolation,
|
|
90
|
+
sanitizeSpiralConfig,
|
|
91
|
+
parseReviewVerdict,
|
|
92
|
+
latestReviewFilesPerGate,
|
|
93
|
+
type ReviewStreakState,
|
|
94
|
+
} from "./review-analysis.ts";
|
|
95
|
+
// NOTE: emitEngineEvent is NOT statically imported from ./persistence.ts.
|
|
96
|
+
// persistence.ts imports execLog from ./execution.ts, and execution.ts imports
|
|
97
|
+
// executeTaskV2 from this module — a static import here would form a
|
|
98
|
+
// lane-runner → persistence → execution → lane-runner cycle. Beyond being a
|
|
99
|
+
// smell, that eager cycle pre-binds execution's executeTaskV2 to the real
|
|
100
|
+
// export before tests can mock.module("lane-runner"), defeating the mock. The
|
|
101
|
+
// review-event bridge below loads emitEngineEvent lazily (cached) instead.
|
|
102
|
+
let cachedEmitEngineEvent: ((stateRoot: string, event: EngineEvent) => void) | null = null;
|
|
73
103
|
|
|
74
104
|
const LANE_RUNNER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
75
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Interval (ms) for the live worker-outbox poll during a running worker
|
|
108
|
+
* (mail-recognition fix). Surfaces reply/escalate mail to the supervisor
|
|
109
|
+
* mid-run instead of only after the worker exits. 3s balances responsiveness
|
|
110
|
+
* against fs churn; the post-exit final drain catches any last stragglers.
|
|
111
|
+
*/
|
|
112
|
+
const OUTBOX_LIVE_POLL_INTERVAL_MS = 3_000;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* #629: how many worker iterations the lane may spend REMEDIATING an
|
|
116
|
+
* outstanding non-APPROVE review gate when all checkboxes are already
|
|
117
|
+
* checked. Without this path, retry+resume after a finalize refusal never
|
|
118
|
+
* spawns a worker (the loop breaks on "no remaining steps") and the gate
|
|
119
|
+
* refuses again immediately — the alert's promised remedy was unreachable.
|
|
120
|
+
* Bounded so an unresolvable REVISE cannot loop forever.
|
|
121
|
+
*/
|
|
122
|
+
const MAX_REVIEW_REMEDIATION_ITERATIONS = 2;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* #630: how many times the lane relaunches a worker that exited while HOLDING
|
|
126
|
+
* for a supervisor ruling (an `escalate_to_supervisor` with no reply delivered
|
|
127
|
+
* since). Each relaunch re-checks for the ruling; beyond this the task fails
|
|
128
|
+
* with an explicit "hold unresolved" reason so the supervisor sees a dead
|
|
129
|
+
* lane as a governance signal, not as silence. (A first-class `held` state
|
|
130
|
+
* with an in-tool wait is the #627 design follow-up.)
|
|
131
|
+
*/
|
|
132
|
+
const MAX_HOLD_RELAUNCHES = 3;
|
|
133
|
+
|
|
134
|
+
/** A review gate whose LATEST review file carries a non-APPROVE verdict. */
|
|
135
|
+
interface BlockingReviewGate {
|
|
136
|
+
/** `{type}-step{N}` gate key */
|
|
137
|
+
gate: string;
|
|
138
|
+
/** Latest review filename for that gate */
|
|
139
|
+
filename: string;
|
|
140
|
+
verdict: "REVISE" | "RETHINK";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Scan a reviews directory and return every gate whose latest review file
|
|
145
|
+
* reads REVISE/RETHINK (#626 minimal finalize gate). Unreadable files are
|
|
146
|
+
* never blockers; a scan failure yields an empty list (fail-safe for
|
|
147
|
+
* finalization, which must not be corrupted by an fs hiccup).
|
|
148
|
+
*/
|
|
149
|
+
function findBlockingReviewGates(reviewsDir: string): BlockingReviewGate[] {
|
|
150
|
+
const blocking: BlockingReviewGate[] = [];
|
|
151
|
+
try {
|
|
152
|
+
if (!existsSync(reviewsDir)) return blocking;
|
|
153
|
+
const latest = latestReviewFilesPerGate(readdirSync(reviewsDir));
|
|
154
|
+
for (const [gate, filename] of latest) {
|
|
155
|
+
try {
|
|
156
|
+
const verdict = parseReviewVerdict(readFileSync(join(reviewsDir, filename), "utf-8"));
|
|
157
|
+
if (verdict === "REVISE" || verdict === "RETHINK") blocking.push({ gate, filename, verdict });
|
|
158
|
+
} catch {
|
|
159
|
+
/* unreadable review file — not a blocker */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
/* best effort */
|
|
164
|
+
}
|
|
165
|
+
return blocking;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `code-step4` → 4; null when the gate key has no step suffix. */
|
|
169
|
+
function parseGateStepNumber(gate: string): number | null {
|
|
170
|
+
const m = /-step(\d+)$/i.exec(gate);
|
|
171
|
+
return m ? Number(m[1]) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatBlockingGates(gates: BlockingReviewGate[]): string {
|
|
175
|
+
return gates.map((g) => `${g.gate} (${g.filename}: ${g.verdict})`).join("; ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Default severity vocabulary when the reviewer config doesn't override it. */
|
|
179
|
+
const DEFAULT_SEVERITY_LABELS = ["critical", "important", "minor"];
|
|
180
|
+
/** Max recent dispositions retained per step for escalation context. */
|
|
181
|
+
const RECENT_DISPOSITIONS_CAP = 6;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* In-memory per-step review-boundary state = the shared streak model plus
|
|
185
|
+
* live-only escalation cooldown bookkeeping.
|
|
186
|
+
*/
|
|
187
|
+
interface ReviewStepState extends ReviewStreakState {
|
|
188
|
+
/** Round at which the spiral escalation last fired (for cooldown); null = never. */
|
|
189
|
+
lastEscalationRound: number | null;
|
|
190
|
+
/** Round at which an order-violation last escalated (for cooldown); null = never. */
|
|
191
|
+
lastRefusedRound: number | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resume reconstruction: seed per-step review streak state by replaying a task's
|
|
196
|
+
* prior review END boundaries from `.pi/supervisor/events.jsonl`. Best-effort
|
|
197
|
+
* (an optimization, not correctness-critical): any read/parse failure leaves the
|
|
198
|
+
* map empty and detection simply starts fresh. Escalation cooldown fields reset
|
|
199
|
+
* to null so an ongoing spiral re-alerts the supervisor after resume.
|
|
200
|
+
*/
|
|
201
|
+
function seedReviewStateFromHistory(
|
|
202
|
+
target: Map<string, ReviewStepState>,
|
|
203
|
+
stateRoot: string,
|
|
204
|
+
batchId: string,
|
|
205
|
+
taskId: string,
|
|
206
|
+
treatUnavailableAsNonApprove: boolean,
|
|
207
|
+
): void {
|
|
208
|
+
try {
|
|
209
|
+
const eventsPath = join(stateRoot, ".pi", "supervisor", "events.jsonl");
|
|
210
|
+
if (!existsSync(eventsPath)) return;
|
|
211
|
+
const raw = readFileSync(eventsPath, "utf-8");
|
|
212
|
+
const events: Array<{
|
|
213
|
+
reviewStep?: number;
|
|
214
|
+
disposition?: string;
|
|
215
|
+
findingCounts?: Record<string, number> | null;
|
|
216
|
+
}> = [];
|
|
217
|
+
for (const line of raw.split("\n")) {
|
|
218
|
+
const trimmed = line.trim();
|
|
219
|
+
if (!trimmed) continue;
|
|
220
|
+
try {
|
|
221
|
+
const e = JSON.parse(trimmed) as Record<string, unknown>;
|
|
222
|
+
if (
|
|
223
|
+
(e.type === "review_completed" || e.type === "review_failed") &&
|
|
224
|
+
e.batchId === batchId &&
|
|
225
|
+
e.taskId === taskId
|
|
226
|
+
) {
|
|
227
|
+
events.push({
|
|
228
|
+
reviewStep: typeof e.reviewStep === "number" ? e.reviewStep : undefined,
|
|
229
|
+
disposition: typeof e.disposition === "string" ? e.disposition : undefined,
|
|
230
|
+
findingCounts:
|
|
231
|
+
e.findingCounts && typeof e.findingCounts === "object"
|
|
232
|
+
? (e.findingCounts as Record<string, number>)
|
|
233
|
+
: null,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
/* skip malformed line */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (events.length === 0) return;
|
|
241
|
+
const streaks = reconstructReviewStreaks(events, {
|
|
242
|
+
treatUnavailableAsNonApprove,
|
|
243
|
+
recentCap: RECENT_DISPOSITIONS_CAP,
|
|
244
|
+
});
|
|
245
|
+
for (const [stepStr, streak] of streaks) {
|
|
246
|
+
target.set(`${taskId}:${stepStr}`, {
|
|
247
|
+
...streak,
|
|
248
|
+
lastEscalationRound: null,
|
|
249
|
+
lastRefusedRound: null,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
/* best effort */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
76
257
|
// ── Segment Scoping Helpers (Phase A, TP-174) ────────────────────────
|
|
77
258
|
|
|
78
259
|
/**
|
|
@@ -296,6 +477,13 @@ export interface LaneRunnerConfig {
|
|
|
296
477
|
* @since TP-160
|
|
297
478
|
*/
|
|
298
479
|
reviewerTools: string;
|
|
480
|
+
/**
|
|
481
|
+
* Ordered severity vocabulary for review finding-count analysis (review-boundary
|
|
482
|
+
* notifications). Undefined → lane-runner default (critical/important/minor).
|
|
483
|
+
*/
|
|
484
|
+
reviewSeverityLabels?: string[];
|
|
485
|
+
/** Revision-spiral detection tuning. Undefined → lane-runner defaults. */
|
|
486
|
+
reviewSpiral?: import("./config-schema.ts").ReviewSpiralConfig;
|
|
299
487
|
/** Supervisor autonomy level for bridge-tool guards. */
|
|
300
488
|
supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
|
|
301
489
|
/** Project name (for review request context) */
|
|
@@ -308,6 +496,8 @@ export interface LaneRunnerConfig {
|
|
|
308
496
|
maxIterations: number;
|
|
309
497
|
/** No-progress stall limit */
|
|
310
498
|
noProgressLimit: number;
|
|
499
|
+
/** Exit-intercept supervisor-reply window (seconds; default 60; 15..1800). */
|
|
500
|
+
exitInterceptTimeoutSec?: number;
|
|
311
501
|
/** Max worker time in minutes per iteration */
|
|
312
502
|
maxWorkerMinutes: number;
|
|
313
503
|
/** Context pressure warn threshold (0-100) */
|
|
@@ -369,7 +559,7 @@ export interface LaneRunnerTaskResult {
|
|
|
369
559
|
export async function executeTaskV2(
|
|
370
560
|
unit: ExecutionUnit,
|
|
371
561
|
config: LaneRunnerConfig,
|
|
372
|
-
pauseSignal:
|
|
562
|
+
pauseSignal: PauseSignal,
|
|
373
563
|
): Promise<LaneRunnerTaskResult> {
|
|
374
564
|
const startTime = Date.now();
|
|
375
565
|
const statusPath = unit.packet.statusPath;
|
|
@@ -381,6 +571,380 @@ export async function executeTaskV2(
|
|
|
381
571
|
const segmentId = unit.segmentId;
|
|
382
572
|
const workerAgentId = buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "worker");
|
|
383
573
|
|
|
574
|
+
// ── Live outbox surfacing (mail-recognition fix) ─────────────────
|
|
575
|
+
// Worker reply/escalate mail (notify_supervisor / escalate_to_supervisor
|
|
576
|
+
// → *.msg.json) must reach the supervisor WHILE the worker is still
|
|
577
|
+
// running — e.g. a worker asking for help to break a review spiral. The
|
|
578
|
+
// original code only read the outbox AFTER the worker subprocess exited
|
|
579
|
+
// (post-exit block below), so mid-run mail sat unread until exit and the
|
|
580
|
+
// supervisor "woke up" too late. This helper surfaces + acks each pending
|
|
581
|
+
// reply/escalate message; it runs on a live timer during the worker run
|
|
582
|
+
// (see the interval around `await spawned.promise`) AND once more after
|
|
583
|
+
// exit as a final drain. Acking (ackOutboxMessage) moves each message to
|
|
584
|
+
// processed/, so the live timer and the post-exit drain never
|
|
585
|
+
// double-surface the same message. Re-entrancy guarded so a slow cycle
|
|
586
|
+
// can't overlap the next tick.
|
|
587
|
+
let outboxDraining = false;
|
|
588
|
+
const drainAndSurfaceOutbox = (): void => {
|
|
589
|
+
if (outboxDraining) return;
|
|
590
|
+
outboxDraining = true;
|
|
591
|
+
try {
|
|
592
|
+
const outboxMessages = readOutbox(config.stateRoot, config.batchId, workerAgentId);
|
|
593
|
+
for (const msg of outboxMessages) {
|
|
594
|
+
const sanitized = msg.content.replace(/\r?\n/g, " / ").slice(0, 200);
|
|
595
|
+
logExecution(statusPath, `Agent ${msg.type}`, sanitized);
|
|
596
|
+
|
|
597
|
+
if (msg.type === "reply" || msg.type === "escalate") {
|
|
598
|
+
appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
|
|
599
|
+
batchId: config.batchId,
|
|
600
|
+
agentId: workerAgentId,
|
|
601
|
+
role: "worker",
|
|
602
|
+
laneNumber: config.laneNumber,
|
|
603
|
+
taskId,
|
|
604
|
+
repoId: config.repoId,
|
|
605
|
+
ts: Date.now(),
|
|
606
|
+
type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
|
|
607
|
+
payload: {
|
|
608
|
+
messageId: msg.id,
|
|
609
|
+
replyTo: msg.replyTo ?? null,
|
|
610
|
+
content: sanitized,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
appendMailboxAuditEvent(config.stateRoot, config.batchId, {
|
|
615
|
+
type: msg.type === "reply" ? "message_replied" : "message_escalated",
|
|
616
|
+
from: workerAgentId,
|
|
617
|
+
to: "supervisor",
|
|
618
|
+
messageId: msg.id,
|
|
619
|
+
messageType: msg.type,
|
|
620
|
+
contentPreview: sanitized,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (msg.type === "escalate" && msg.timestamp > lastSupervisorReplyTs) {
|
|
624
|
+
// #630: the worker is now waiting for a ruling. Remember it so an
|
|
625
|
+
// exit before a reply is treated as a hold, not a premature stop.
|
|
626
|
+
// Causal timestamp = the message's own creation time (a reply created after
|
|
627
|
+
// the escalation but before this drain must still count as a ruling).
|
|
628
|
+
// An escalation older than the last reply we saw was already answered
|
|
629
|
+
// (e.g. consumed by the exit-intercept before this drain) — no hold.
|
|
630
|
+
pendingEscalation = { id: msg.id, ts: msg.timestamp, preview: sanitized };
|
|
631
|
+
}
|
|
632
|
+
if (config.onSupervisorAlert) {
|
|
633
|
+
const isEscalation = msg.type === "escalate";
|
|
634
|
+
try {
|
|
635
|
+
config.onSupervisorAlert({
|
|
636
|
+
category: "agent-message",
|
|
637
|
+
summary:
|
|
638
|
+
`${isEscalation ? "\uD83D\uDEA8" : "\uD83D\uDCE8"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
|
|
639
|
+
` Task: ${taskId}\n` +
|
|
640
|
+
` Lane: lane-${config.laneNumber}\n` +
|
|
641
|
+
` Message: ${sanitized}`,
|
|
642
|
+
context: {
|
|
643
|
+
taskId,
|
|
644
|
+
laneId: `lane-${config.laneNumber}`,
|
|
645
|
+
laneNumber: config.laneNumber,
|
|
646
|
+
agentId: workerAgentId,
|
|
647
|
+
messageId: msg.id,
|
|
648
|
+
exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
} catch {
|
|
652
|
+
/* best effort */
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Consume outbox message to prevent duplicate processing by the
|
|
658
|
+
// next live tick or the post-exit final drain.
|
|
659
|
+
ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
/* best effort */
|
|
663
|
+
} finally {
|
|
664
|
+
outboxDraining = false;
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// ── Review-boundary bridge + spiral detection (supervisor notifications) ──
|
|
669
|
+
// agent-host emits per-agent RuntimeAgentEvents review_requested /
|
|
670
|
+
// review_completed / review_failed as the worker calls the review_step tool.
|
|
671
|
+
// This handler: (1) bridges every boundary to the supervisor's live
|
|
672
|
+
// events.jsonl stream (emitEngineEvent) enriched with finding counts + trend
|
|
673
|
+
// + round, so the supervisor adjudicates each review case-by-case; and
|
|
674
|
+
// (2) tracks per-step spiral state and fires an actionable escalation
|
|
675
|
+
// (review-intervention-needed) when a step's reviews circle without
|
|
676
|
+
// converging, or when the worker trips the order-of-operations guard (REFUSED).
|
|
677
|
+
const reviewSeverityLabels =
|
|
678
|
+
config.reviewSeverityLabels && config.reviewSeverityLabels.length > 0
|
|
679
|
+
? config.reviewSeverityLabels
|
|
680
|
+
: DEFAULT_SEVERITY_LABELS;
|
|
681
|
+
// Sanitize (clamp threshold/cooldown >= 1, coerce booleans) so malformed
|
|
682
|
+
// config threaded via env can't cause escalate-every-review or nag-every-round.
|
|
683
|
+
// NOTE: `enabled` is the master switch for BOTH spiral and order-violation
|
|
684
|
+
// STEER escalations. When disabled, REFUSED/spiral still appear as ordinary
|
|
685
|
+
// per-boundary notifications (formatEventNotification) — just not as urgent
|
|
686
|
+
// steer interrupts.
|
|
687
|
+
const spiralCfg = sanitizeSpiralConfig(config.reviewSpiral);
|
|
688
|
+
const reviewStateByStep = new Map<string, ReviewStepState>();
|
|
689
|
+
// Resume reconstruction ("maintain the truth"): rebuild per-step streak state
|
|
690
|
+
// by replaying this task's prior review boundaries from events.jsonl, so a
|
|
691
|
+
// spiral in progress before a pause/resume isn't silently reset to zero.
|
|
692
|
+
seedReviewStateFromHistory(
|
|
693
|
+
reviewStateByStep,
|
|
694
|
+
config.stateRoot,
|
|
695
|
+
config.batchId,
|
|
696
|
+
taskId,
|
|
697
|
+
spiralCfg.treatUnavailableAsNonApprove,
|
|
698
|
+
);
|
|
699
|
+
const getReviewState = (stepKey: string): ReviewStepState => {
|
|
700
|
+
let st = reviewStateByStep.get(stepKey);
|
|
701
|
+
if (!st) {
|
|
702
|
+
st = { ...freshReviewStreakState(), lastEscalationRound: null, lastRefusedRound: null };
|
|
703
|
+
reviewStateByStep.set(stepKey, st);
|
|
704
|
+
}
|
|
705
|
+
return st;
|
|
706
|
+
};
|
|
707
|
+
// Read + parse finding counts from the exact review file agent-host referenced.
|
|
708
|
+
// Read the exact review file agent-host referenced (best-effort). Reused for
|
|
709
|
+
// both the authoritative verdict (#624) and the severity finding counts.
|
|
710
|
+
const readReviewFile = (reviewPath?: string): string | null => {
|
|
711
|
+
if (!reviewPath) return null;
|
|
712
|
+
try {
|
|
713
|
+
const abs = join(unit.packet.reviewsDir, basename(reviewPath));
|
|
714
|
+
if (!existsSync(abs)) return null;
|
|
715
|
+
return readFileSync(abs, "utf-8");
|
|
716
|
+
} catch {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
// Fire an actionable review-intervention escalation. Delivery is set to steer
|
|
721
|
+
// (urgent) by category in the IPC handler (extension.ts).
|
|
722
|
+
const fireIntervention = (
|
|
723
|
+
kind: ReviewInterventionKind,
|
|
724
|
+
stepNum: number | undefined,
|
|
725
|
+
reviewType: string | undefined,
|
|
726
|
+
state: ReviewStepState,
|
|
727
|
+
ev: EngineEvent,
|
|
728
|
+
): void => {
|
|
729
|
+
if (!config.onSupervisorAlert) return;
|
|
730
|
+
const loc = `${taskId}${stepNum !== undefined ? ` step ${stepNum}` : ""} (lane ${config.laneNumber})`;
|
|
731
|
+
const label = ev.reviewLabel ? ` [${ev.reviewLabel}]` : "";
|
|
732
|
+
const countsStr = ev.findingCounts
|
|
733
|
+
? Object.entries(ev.findingCounts)
|
|
734
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
735
|
+
.join(" ")
|
|
736
|
+
: "n/a";
|
|
737
|
+
const summary =
|
|
738
|
+
kind === "revision-spiral"
|
|
739
|
+
? `🌀 **Review spiral** — ${loc}${label}: ${state.consecutiveNonApprove} consecutive ` +
|
|
740
|
+
`non-approve reviews (latest ${ev.disposition ?? "?"}). Findings: ${countsStr}; ` +
|
|
741
|
+
`severity trend ${ev.findingTrend ?? "?"}${ev.findingMixed ? " (mixed)" : ""}.\n` +
|
|
742
|
+
`Adjudicate: steer the worker to a resolution — implement the remaining legitimate ` +
|
|
743
|
+
`findings, or if the reviews are circling the same class, tell it to stop and log a blocker.`
|
|
744
|
+
: `⛔ **Review order violation** — ${loc}${label}: the worker marked the step complete ` +
|
|
745
|
+
`before code review ran (REFUSED). Steer it to revert the premature completion and ` +
|
|
746
|
+
`re-review, or log a blocker.`;
|
|
747
|
+
const alert: SupervisorAlert = {
|
|
748
|
+
category: "review-intervention-needed",
|
|
749
|
+
summary,
|
|
750
|
+
context: {
|
|
751
|
+
taskId,
|
|
752
|
+
laneId: `lane-${config.laneNumber}`,
|
|
753
|
+
laneNumber: config.laneNumber,
|
|
754
|
+
agentId: workerAgentId,
|
|
755
|
+
reviewInterventionKind: kind,
|
|
756
|
+
reviewStep: stepNum,
|
|
757
|
+
reviewType,
|
|
758
|
+
reviewRound: ev.reviewRound,
|
|
759
|
+
reviewLabel: ev.reviewLabel,
|
|
760
|
+
disposition: ev.disposition,
|
|
761
|
+
recentDispositions: [...state.recentDispositions],
|
|
762
|
+
consecutiveNonApprove: state.consecutiveNonApprove,
|
|
763
|
+
findingCounts: ev.findingCounts,
|
|
764
|
+
findingTrend: ev.findingTrend,
|
|
765
|
+
findingDeltas: ev.findingDeltas,
|
|
766
|
+
findingMixed: ev.findingMixed,
|
|
767
|
+
},
|
|
768
|
+
};
|
|
769
|
+
try {
|
|
770
|
+
config.onSupervisorAlert(alert);
|
|
771
|
+
} catch {
|
|
772
|
+
/* best effort */
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// Idempotency for END boundaries (Penster feedback on #632: a duplicate
|
|
777
|
+
// review_completed for R002 reached the supervisor). Whatever the upstream
|
|
778
|
+
// cause — a retried tool turn, a re-invoked review_step for the same round,
|
|
779
|
+
// or a doubled RPC event — one REVIEW FILE is one review: the second end
|
|
780
|
+
// boundary for the same (step, reviewType, reviewPath) must neither notify
|
|
781
|
+
// again nor advance the spiral streak again (which would fire the spiral a
|
|
782
|
+
// round early). Keyed by the resolved review path; falls back to the raw
|
|
783
|
+
// event identity when no path is known.
|
|
784
|
+
const seenReviewEnds = new Set<string>();
|
|
785
|
+
const bridgeReviewEvent = (evt: RuntimeAgentEvent): void => {
|
|
786
|
+
if (
|
|
787
|
+
evt.type !== "review_requested" &&
|
|
788
|
+
evt.type !== "review_completed" &&
|
|
789
|
+
evt.type !== "review_failed"
|
|
790
|
+
) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const payload = (evt.payload ?? {}) as {
|
|
794
|
+
step?: unknown;
|
|
795
|
+
reviewType?: unknown;
|
|
796
|
+
disposition?: unknown;
|
|
797
|
+
reviewPath?: unknown;
|
|
798
|
+
};
|
|
799
|
+
const stepNum = typeof payload.step === "number" ? payload.step : undefined;
|
|
800
|
+
const reviewType = typeof payload.reviewType === "string" ? payload.reviewType : undefined;
|
|
801
|
+
const payloadDisposition =
|
|
802
|
+
typeof payload.disposition === "string" ? (payload.disposition as ReviewDisposition) : undefined;
|
|
803
|
+
const reviewPath = typeof payload.reviewPath === "string" ? payload.reviewPath : undefined;
|
|
804
|
+
const isEnd = evt.type !== "review_requested";
|
|
805
|
+
|
|
806
|
+
// #624: the review FILE's `## Verdict:` is the authoritative disposition,
|
|
807
|
+
// overriding the upstream tool-return parse (which can miss on structured
|
|
808
|
+
// results). Read the file ONCE here; reused for finding counts below.
|
|
809
|
+
const reviewMd = isEnd ? readReviewFile(reviewPath) : null;
|
|
810
|
+
const fileVerdict = parseReviewVerdict(reviewMd);
|
|
811
|
+
const disposition = fileVerdict ?? payloadDisposition;
|
|
812
|
+
|
|
813
|
+
if (isEnd) {
|
|
814
|
+
const key = reviewPath
|
|
815
|
+
? `${stepNum ?? "?"}:${reviewType ?? "?"}:${reviewPath}`
|
|
816
|
+
: `${stepNum ?? "?"}:${reviewType ?? "?"}:${evt.ts}:${disposition ?? "?"}`;
|
|
817
|
+
if (seenReviewEnds.has(key)) {
|
|
818
|
+
logExecution(
|
|
819
|
+
statusPath,
|
|
820
|
+
"Duplicate review boundary",
|
|
821
|
+
`ignored repeated ${evt.type} for ${key.slice(0, 120)} (idempotency, one review file = one review)`,
|
|
822
|
+
);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
seenReviewEnds.add(key);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Classify by the RESOLVED disposition: only a genuine UNAVAILABLE / total
|
|
829
|
+
// parse-miss (no verdict in the tool return AND none on disk) is
|
|
830
|
+
// review_failed. A real verdict — including one recovered from the file — is
|
|
831
|
+
// review_completed. This is what prevents the spurious "Reviewer
|
|
832
|
+
// unavailable" on every successful review (#624).
|
|
833
|
+
const engineType: EngineEventType = !isEnd
|
|
834
|
+
? "review_started"
|
|
835
|
+
: disposition === "UNAVAILABLE" || disposition === "UNKNOWN" || disposition === undefined
|
|
836
|
+
? "review_failed"
|
|
837
|
+
: "review_completed";
|
|
838
|
+
|
|
839
|
+
const engineEvent: EngineEvent = {
|
|
840
|
+
timestamp: new Date().toISOString(),
|
|
841
|
+
type: engineType,
|
|
842
|
+
batchId: config.batchId,
|
|
843
|
+
waveIndex: -1,
|
|
844
|
+
phase: "executing",
|
|
845
|
+
taskId,
|
|
846
|
+
laneNumber: config.laneNumber,
|
|
847
|
+
agentId: workerAgentId,
|
|
848
|
+
reviewStep: stepNum,
|
|
849
|
+
reviewType,
|
|
850
|
+
disposition,
|
|
851
|
+
reviewPath,
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// Detection + enrichment on END boundaries (completed/failed) with a step.
|
|
855
|
+
if (isEnd && stepNum !== undefined) {
|
|
856
|
+
const state = getReviewState(`${taskId}:${stepNum}`);
|
|
857
|
+
const counts = reviewMd ? parseFindingCounts(reviewMd, reviewSeverityLabels) : {};
|
|
858
|
+
const hasCounts = Object.keys(counts).length > 0;
|
|
859
|
+
// Trend compares the PRIOR round's counts to this round's, so compute it
|
|
860
|
+
// BEFORE advancing the streak (which overwrites lastCounts). Semantics:
|
|
861
|
+
// a countless round (e.g. APPROVE, or an unparseable review) preserves the
|
|
862
|
+
// prior lastCounts, so the NEXT counted round trends vs the last COUNTED
|
|
863
|
+
// round — intentional, so a single missing review file doesn't blank the
|
|
864
|
+
// severity trend the supervisor relies on.
|
|
865
|
+
const trendRes = computeFindingTrend(state.lastCounts, counts, reviewSeverityLabels);
|
|
866
|
+
// Shared streak transition (round++, lastCounts, recentDispositions,
|
|
867
|
+
// consecutiveNonApprove) — identical to resume reconstruction.
|
|
868
|
+
advanceReviewStreak(state, {
|
|
869
|
+
disposition,
|
|
870
|
+
counts: hasCounts ? counts : null,
|
|
871
|
+
treatUnavailableAsNonApprove: spiralCfg.treatUnavailableAsNonApprove,
|
|
872
|
+
recentCap: RECENT_DISPOSITIONS_CAP,
|
|
873
|
+
});
|
|
874
|
+
engineEvent.reviewRound = state.round;
|
|
875
|
+
engineEvent.reviewLabel = parseReviewLabelFromPath(reviewPath);
|
|
876
|
+
if (hasCounts) {
|
|
877
|
+
engineEvent.findingCounts = counts;
|
|
878
|
+
engineEvent.findingTrend = trendRes.trend;
|
|
879
|
+
engineEvent.findingDeltas = trendRes.deltas;
|
|
880
|
+
engineEvent.findingMixed = trendRes.mixed;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Live-only escalation (the streak counter was already advanced above).
|
|
884
|
+
if (disposition === "APPROVE") {
|
|
885
|
+
state.lastEscalationRound = null; // a fresh streak may escalate again
|
|
886
|
+
} else if (
|
|
887
|
+
disposition === "REVISE" ||
|
|
888
|
+
disposition === "RETHINK" ||
|
|
889
|
+
(disposition === "UNAVAILABLE" && spiralCfg.treatUnavailableAsNonApprove)
|
|
890
|
+
) {
|
|
891
|
+
maybeFireSpiral(stepNum, reviewType, state, engineEvent);
|
|
892
|
+
} else if (disposition === "REFUSED") {
|
|
893
|
+
// Orthogonal failure mode: does NOT touch the REVISE/RETHINK streak.
|
|
894
|
+
maybeFireOrderViolation(stepNum, reviewType, state, engineEvent);
|
|
895
|
+
}
|
|
896
|
+
// UNAVAILABLE (not counted) / UNKNOWN → no counter change.
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const emit = (fn: (stateRoot: string, event: EngineEvent) => void): void => {
|
|
900
|
+
try {
|
|
901
|
+
fn(config.stateRoot, engineEvent);
|
|
902
|
+
} catch {
|
|
903
|
+
/* best effort — a bridge failure must never break the worker run */
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
// Cached lazy import (see the import-cycle note at the top of this file).
|
|
907
|
+
if (cachedEmitEngineEvent) {
|
|
908
|
+
emit(cachedEmitEngineEvent);
|
|
909
|
+
} else {
|
|
910
|
+
void import("./persistence.ts")
|
|
911
|
+
.then((m) => {
|
|
912
|
+
cachedEmitEngineEvent = m.emitEngineEvent;
|
|
913
|
+
emit(m.emitEngineEvent);
|
|
914
|
+
})
|
|
915
|
+
.catch(() => {
|
|
916
|
+
/* best effort */
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// Spiral escalation: first fire at threshold; re-fire only if NOT converging
|
|
922
|
+
// (trend flat/rising) and the cooldown spacing has elapsed.
|
|
923
|
+
function maybeFireSpiral(
|
|
924
|
+
stepNum: number | undefined,
|
|
925
|
+
reviewType: string | undefined,
|
|
926
|
+
state: ReviewStepState,
|
|
927
|
+
ev: EngineEvent,
|
|
928
|
+
): void {
|
|
929
|
+
if (shouldFireSpiral(state, spiralCfg, ev.findingTrend)) {
|
|
930
|
+
fireIntervention("revision-spiral", stepNum, reviewType, state, ev);
|
|
931
|
+
state.lastEscalationRound = state.round;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Order-violation escalation: actionable each occurrence, throttled by cooldown.
|
|
936
|
+
function maybeFireOrderViolation(
|
|
937
|
+
stepNum: number | undefined,
|
|
938
|
+
reviewType: string | undefined,
|
|
939
|
+
state: ReviewStepState,
|
|
940
|
+
ev: EngineEvent,
|
|
941
|
+
): void {
|
|
942
|
+
if (shouldFireOrderViolation(state, spiralCfg)) {
|
|
943
|
+
fireIntervention("order-violation", stepNum, reviewType, state, ev);
|
|
944
|
+
state.lastRefusedRound = state.round;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
384
948
|
// ── 1. Ensure STATUS.md exists ──────────────────────────────────
|
|
385
949
|
if (!existsSync(statusPath)) {
|
|
386
950
|
const content = readFileSync(promptPath, "utf-8");
|
|
@@ -416,6 +980,35 @@ export async function executeTaskV2(
|
|
|
416
980
|
|
|
417
981
|
// ── 2. Iteration loop ───────────────────────────────────────────
|
|
418
982
|
let noProgressCount = 0;
|
|
983
|
+
// TP-145: Is this a non-final segment of a multi-segment task? If more
|
|
984
|
+
// segments follow, .DONE creation is suppressed after the loop so the engine
|
|
985
|
+
// can advance the segment frontier. Loop-invariant; also consulted by the
|
|
986
|
+
// #629 remediation spawn (the finalize gate never applies to a non-final
|
|
987
|
+
// segment).
|
|
988
|
+
const isNonFinalSegment =
|
|
989
|
+
segmentId != null &&
|
|
990
|
+
Array.isArray(unit.task.segmentIds) &&
|
|
991
|
+
unit.task.segmentIds.length > 1 &&
|
|
992
|
+
unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
|
|
993
|
+
/** #629: review-gate remediation iterations spent (bounded). */
|
|
994
|
+
let remediationIterations = 0;
|
|
995
|
+
/**
|
|
996
|
+
* #630: the most recent escalation the worker filed that has NOT yet been
|
|
997
|
+
* answered by a steer (set when surfaced; cleared when a steer is delivered
|
|
998
|
+
* after it). While set, a clean worker exit is a HOLD exit, not a stall.
|
|
999
|
+
*/
|
|
1000
|
+
let pendingEscalation: { id: string; ts: number; preview: string } | null = null;
|
|
1001
|
+
/** #630: relaunches spent re-checking for a ruling (bounded). */
|
|
1002
|
+
let holdRelaunches = 0;
|
|
1003
|
+
/**
|
|
1004
|
+
* #630: creation timestamp of the most recent supervisor reply we have seen
|
|
1005
|
+
* (a steer delivered to the worker, or an instructional reply consumed by the
|
|
1006
|
+
* exit-intercept). An escalation drained LATER but CREATED before this reply
|
|
1007
|
+
* was already answered — it must not (re)create a hold.
|
|
1008
|
+
*/
|
|
1009
|
+
let lastSupervisorReplyTs = 0;
|
|
1010
|
+
/** #629: gates the CURRENT iteration was spawned to remediate (empty = normal iteration). */
|
|
1011
|
+
let remediationGates: BlockingReviewGate[] = [];
|
|
419
1012
|
let totalIterations = 0;
|
|
420
1013
|
let cumulativeCostUsd = 0;
|
|
421
1014
|
let cumulativeTokens = 0;
|
|
@@ -434,14 +1027,28 @@ export async function executeTaskV2(
|
|
|
434
1027
|
})()
|
|
435
1028
|
: null;
|
|
436
1029
|
|
|
437
|
-
|
|
1030
|
+
// Productive-iteration budget. Hold exits (worker idles awaiting a ruling)
|
|
1031
|
+
// do NOT consume it — they are bounded separately (MAX_HOLD_RELAUNCHES,
|
|
1032
|
+
// reset by acknowledgements). Without this, a correctly-holding lane hit
|
|
1033
|
+
// maxIterations (10) inside an hour (penster 20260906T194514). Explicit
|
|
1034
|
+
// counter rather than `iter--` so the two budgets stay legible.
|
|
1035
|
+
let productiveIterations = 0;
|
|
1036
|
+
for (; productiveIterations < config.maxIterations; productiveIterations++) {
|
|
438
1037
|
if (pauseSignal.paused) {
|
|
439
|
-
|
|
1038
|
+
// A pause is NOT a terminal outcome. Returning "skipped" here converted a
|
|
1039
|
+
// correctly-holding task into a skipped one and let a single-wave batch
|
|
1040
|
+
// complete 0/1 and clean up its worktree (penster 20260906T194514).
|
|
1041
|
+
// "pending" keeps the task re-executable on resume in this worktree.
|
|
1042
|
+
logExecution(
|
|
1043
|
+
statusPath,
|
|
1044
|
+
"Paused",
|
|
1045
|
+
`Paused at iteration ${totalIterations} — task remains pending`,
|
|
1046
|
+
);
|
|
440
1047
|
return makeResult(
|
|
441
1048
|
taskId,
|
|
442
1049
|
segmentId,
|
|
443
1050
|
workerAgentId,
|
|
444
|
-
"
|
|
1051
|
+
"pending",
|
|
445
1052
|
startTime,
|
|
446
1053
|
"Paused by user",
|
|
447
1054
|
false,
|
|
@@ -475,7 +1082,7 @@ export async function executeTaskV2(
|
|
|
475
1082
|
// TP-174: Read STATUS.md content once for segment-scoped checks
|
|
476
1083
|
const iterStatusContent = readFileSync(statusPath, "utf-8");
|
|
477
1084
|
|
|
478
|
-
|
|
1085
|
+
let remainingSteps = parsed.steps.filter((step) => {
|
|
479
1086
|
// TP-174: When segment-scoped, only show steps that have work for this repoId
|
|
480
1087
|
if (repoStepNumbers && !repoStepNumbers.has(step.number)) return false;
|
|
481
1088
|
// TP-174: Use segment-scoped completion check in segment mode
|
|
@@ -486,7 +1093,45 @@ export async function executeTaskV2(
|
|
|
486
1093
|
return !isStepComplete(ss);
|
|
487
1094
|
});
|
|
488
1095
|
|
|
489
|
-
|
|
1096
|
+
// ── #629: review-gate remediation spawn ─────────────────────
|
|
1097
|
+
// All checkboxes checked, but would the finalize gate refuse? If a gate's
|
|
1098
|
+
// latest review is REVISE/RETHINK, breaking here means retry+resume never
|
|
1099
|
+
// launches a worker and the refusal simply repeats. Spawn a bounded
|
|
1100
|
+
// remediation iteration instead: the worker addresses the findings and
|
|
1101
|
+
// re-runs review_step to obtain an APPROVE. Segment-scoped iterations
|
|
1102
|
+
// (non-final segments) never finalize, so the gate does not apply there.
|
|
1103
|
+
remediationGates = [];
|
|
1104
|
+
if (remainingSteps.length === 0) {
|
|
1105
|
+
const isFinalizingIteration = !isNonFinalSegment;
|
|
1106
|
+
const blocking = isFinalizingIteration ? findBlockingReviewGates(unit.packet.reviewsDir) : [];
|
|
1107
|
+
if (blocking.length === 0) break; // All done
|
|
1108
|
+
if (remediationIterations >= MAX_REVIEW_REMEDIATION_ITERATIONS) {
|
|
1109
|
+
logExecution(
|
|
1110
|
+
statusPath,
|
|
1111
|
+
"Review remediation exhausted",
|
|
1112
|
+
`${remediationIterations} remediation iteration(s) did not clear: ${formatBlockingGates(blocking)}`,
|
|
1113
|
+
);
|
|
1114
|
+
break; // fall through to the finalize gate, which refuses with the alert
|
|
1115
|
+
}
|
|
1116
|
+
remediationIterations++;
|
|
1117
|
+
remediationGates = blocking;
|
|
1118
|
+
// Give the iteration an explicit focus step — the step of the first
|
|
1119
|
+
// blocking gate (falling back to the last step) — so every downstream
|
|
1120
|
+
// `remainingSteps[0]` consumer (Current Step field, prompt, checkbox
|
|
1121
|
+
// counting) has a real step to point at. The step is NOT re-marked
|
|
1122
|
+
// in-progress and its checkboxes are untouched.
|
|
1123
|
+
const focusStepNumber = parseGateStepNumber(blocking[0].gate);
|
|
1124
|
+
const focusStep =
|
|
1125
|
+
parsed.steps.find((st) => st.number === focusStepNumber) ??
|
|
1126
|
+
parsed.steps[parsed.steps.length - 1];
|
|
1127
|
+
remainingSteps = focusStep ? [focusStep] : [];
|
|
1128
|
+
if (remainingSteps.length === 0) break; // task has no parseable steps — nothing to remediate
|
|
1129
|
+
logExecution(
|
|
1130
|
+
statusPath,
|
|
1131
|
+
"Review remediation",
|
|
1132
|
+
`all checkboxes complete but latest review is not APPROVE — spawning remediation iteration ${remediationIterations}/${MAX_REVIEW_REMEDIATION_ITERATIONS}: ${formatBlockingGates(blocking)}`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
490
1135
|
|
|
491
1136
|
// TP-196 / #508: Pre-spawn segment-completion check.
|
|
492
1137
|
//
|
|
@@ -500,7 +1145,10 @@ export async function executeTaskV2(
|
|
|
500
1145
|
// `repoStepNumbers` diverge (e.g., legacy/partial-marker tasks).
|
|
501
1146
|
// 3. Gives behavioural tests a clean assertion target (via the pure
|
|
502
1147
|
// helper `shouldSkipSpawnForCompleteSegment`).
|
|
503
|
-
if (
|
|
1148
|
+
if (
|
|
1149
|
+
remediationGates.length === 0 &&
|
|
1150
|
+
shouldSkipSpawnForCompleteSegment(iterStatusContent, repoStepNumbers, currentRepoId)
|
|
1151
|
+
) {
|
|
504
1152
|
logExecution(
|
|
505
1153
|
statusPath,
|
|
506
1154
|
"Pre-spawn segment-completion check",
|
|
@@ -513,14 +1161,17 @@ export async function executeTaskV2(
|
|
|
513
1161
|
updateStatusField(
|
|
514
1162
|
statusPath,
|
|
515
1163
|
"Current Step",
|
|
516
|
-
|
|
1164
|
+
remediationGates.length > 0
|
|
1165
|
+
? `Review remediation — Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`
|
|
1166
|
+
: `Step ${remainingSteps[0].number}: ${remainingSteps[0].name}`,
|
|
517
1167
|
);
|
|
518
1168
|
updateStatusField(statusPath, "Iteration", `${totalIterations}`);
|
|
519
1169
|
|
|
520
|
-
// Mark first incomplete step as in-progress
|
|
1170
|
+
// Mark first incomplete step as in-progress (not during remediation: the
|
|
1171
|
+
// focus step is already complete; its checkboxes/status stay untouched)
|
|
521
1172
|
const firstStep = remainingSteps[0];
|
|
522
1173
|
const firstStepStatus = currentStatus.steps.find((s) => s.number === firstStep.number);
|
|
523
|
-
if (firstStepStatus?.status !== "in-progress") {
|
|
1174
|
+
if (remediationGates.length === 0 && firstStepStatus?.status !== "in-progress") {
|
|
524
1175
|
updateStepStatus(statusPath, firstStep.number, "in-progress");
|
|
525
1176
|
logExecution(statusPath, `Step ${firstStep.number} started`, firstStep.name);
|
|
526
1177
|
}
|
|
@@ -679,7 +1330,42 @@ export async function executeTaskV2(
|
|
|
679
1330
|
}
|
|
680
1331
|
}
|
|
681
1332
|
|
|
682
|
-
if (
|
|
1333
|
+
if (remediationGates.length > 0) {
|
|
1334
|
+
promptLines.push(
|
|
1335
|
+
``,
|
|
1336
|
+
`⛔ REVIEW GATE OUTSTANDING — this task cannot finalize yet.`,
|
|
1337
|
+
`All step checkboxes are checked, but the LATEST review for the following gate(s) is not APPROVE:`,
|
|
1338
|
+
...remediationGates.map(
|
|
1339
|
+
(g) =>
|
|
1340
|
+
` - ${g.gate}: ${g.filename} → ${g.verdict} (see ${join(unit.packet.reviewsDir, g.filename)})`,
|
|
1341
|
+
),
|
|
1342
|
+
``,
|
|
1343
|
+
`Your job in this iteration: read each listed review file, address EVERY finding it raises`,
|
|
1344
|
+
`(fix code, update docs/tests as required), commit, then call review_step for that step again`,
|
|
1345
|
+
`to obtain a fresh review. Repeat until the latest review for each gate is APPROVE.`,
|
|
1346
|
+
`Do NOT write .DONE and do NOT declare the task complete while any gate's latest verdict`,
|
|
1347
|
+
`is REVISE or RETHINK — the runtime will refuse to finalize. Do NOT un-check or re-check`,
|
|
1348
|
+
`step checkboxes. If a finding cannot be addressed, escalate_to_supervisor with specifics.`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (pendingEscalation && totalIterations > 1) {
|
|
1353
|
+
// #630: the previous session exited while HOLDING for a ruling. The
|
|
1354
|
+
// generic "you exited prematurely — work continuously" nag is the WRONG
|
|
1355
|
+
// prompt here: it pushes a correctly-holding worker toward self-release
|
|
1356
|
+
// (the TP-2037 class). Give a hold-resume prompt instead.
|
|
1357
|
+
const ago = Math.max(1, Math.round((Date.now() - pendingEscalation.ts) / 60_000));
|
|
1358
|
+
promptLines.push(
|
|
1359
|
+
``,
|
|
1360
|
+
`⏸️ YOU ARE ON HOLD awaiting a supervisor ruling.`,
|
|
1361
|
+
`You escalated ${ago} min ago (escalation ${pendingEscalation.id}: "${pendingEscalation.preview.slice(0, 160)}").`,
|
|
1362
|
+
`If a supervisor reply is delivered to you at the start of this session (as a steering message), act on it.`,
|
|
1363
|
+
`If NO reply (or only an acknowledgement) has arrived: do NOT proceed past the hold, do NOT self-approve or`,
|
|
1364
|
+
`write .DONE, and do NOT re-send the escalation. Work on anything that does not depend on the ruling`,
|
|
1365
|
+
`(other remediable findings, unaffected checkboxes); otherwise add a one-line status note with`,
|
|
1366
|
+
`notify_supervisor(replyTo="${pendingEscalation.id}") and end your turn; the runtime will relaunch you to re-check.`,
|
|
1367
|
+
);
|
|
1368
|
+
} else if (remediationGates.length === 0 && totalIterations > 1 && remainingSteps.length > 0) {
|
|
683
1369
|
const remainingSet = new Set(remainingSteps.map((s) => s.number));
|
|
684
1370
|
const completedSteps = parsed.steps.filter((s) => !remainingSet.has(s.number));
|
|
685
1371
|
promptLines.push(
|
|
@@ -753,6 +1439,9 @@ export async function executeTaskV2(
|
|
|
753
1439
|
thinking: config.workerThinking || undefined,
|
|
754
1440
|
mailboxDir,
|
|
755
1441
|
steeringPendingPath,
|
|
1442
|
+
// Safety race for one intercept must exceed the configured reply window.
|
|
1443
|
+
exitInterceptSafetyMs:
|
|
1444
|
+
(Math.min(1800, Math.max(15, config.exitInterceptTimeoutSec ?? 60)) + 60) * 1000,
|
|
756
1445
|
eventsPath,
|
|
757
1446
|
exitSummaryPath: eventsPath.replace(/\.jsonl$/, "-exit.json"),
|
|
758
1447
|
timeoutMs: config.maxWorkerMinutes * 60_000,
|
|
@@ -919,10 +1608,15 @@ export async function executeTaskV2(
|
|
|
919
1608
|
/* best effort — don't block on alert failure */
|
|
920
1609
|
}
|
|
921
1610
|
|
|
922
|
-
// Poll worker mailbox inbox for supervisor reply
|
|
923
|
-
|
|
1611
|
+
// Poll worker mailbox inbox for supervisor reply. Window is configurable
|
|
1612
|
+
// (taskRunner.worker.exitInterceptTimeoutSec, default 60s): a supervisor
|
|
1613
|
+
// inside a long tool call cannot answer in 60s (penster feedback #3).
|
|
1614
|
+
const SUPERVISOR_REPLY_TIMEOUT_MS =
|
|
1615
|
+
Math.min(1800, Math.max(15, config.exitInterceptTimeoutSec ?? 60)) * 1000;
|
|
924
1616
|
const POLL_INTERVAL_MS = 2_000;
|
|
925
1617
|
const escalationTimestamp = Date.now();
|
|
1618
|
+
let acceptedReplyTs = 0;
|
|
1619
|
+
let acceptedReplyType = "";
|
|
926
1620
|
const inboxDir = sessionInboxDir(config.stateRoot, config.batchId, workerAgentId);
|
|
927
1621
|
|
|
928
1622
|
const supervisorReply = await new Promise<string | null>((resolve) => {
|
|
@@ -944,6 +1638,8 @@ export async function executeTaskV2(
|
|
|
944
1638
|
} catch {
|
|
945
1639
|
/* best effort */
|
|
946
1640
|
}
|
|
1641
|
+
acceptedReplyTs = message.timestamp;
|
|
1642
|
+
acceptedReplyType = message.type;
|
|
947
1643
|
resolve(message.content);
|
|
948
1644
|
return;
|
|
949
1645
|
}
|
|
@@ -997,6 +1693,21 @@ export async function executeTaskV2(
|
|
|
997
1693
|
"Exit intercept reprompt",
|
|
998
1694
|
`Supervisor provided instructions (${supervisorReply.length} chars) — reprompting worker`,
|
|
999
1695
|
);
|
|
1696
|
+
// #630: a supervisor reply consumed HERE never reaches .steering-pending
|
|
1697
|
+
// (it is returned as the next prompt). It is the ruling: release the hold.
|
|
1698
|
+
if (acceptedReplyType === "info" && pendingEscalation) {
|
|
1699
|
+
// Acknowledgement consumed by the intercept: engaged, still holding.
|
|
1700
|
+
holdRelaunches = 0;
|
|
1701
|
+
logExecution(
|
|
1702
|
+
statusPath,
|
|
1703
|
+
"Hold acknowledged",
|
|
1704
|
+
`supervisor acknowledged escalation ${pendingEscalation.id} (via exit-intercept); still holding`,
|
|
1705
|
+
);
|
|
1706
|
+
} else {
|
|
1707
|
+
lastSupervisorReplyTs = Math.max(lastSupervisorReplyTs, acceptedReplyTs || Date.now());
|
|
1708
|
+
pendingEscalation = null;
|
|
1709
|
+
holdRelaunches = 0;
|
|
1710
|
+
}
|
|
1000
1711
|
return supervisorReply;
|
|
1001
1712
|
}
|
|
1002
1713
|
: undefined,
|
|
@@ -1025,7 +1736,7 @@ export async function executeTaskV2(
|
|
|
1025
1736
|
let workerKillReason: "context" | "timer" | null = null;
|
|
1026
1737
|
let iterationTelemetry: Partial<AgentHostResult> = {};
|
|
1027
1738
|
|
|
1028
|
-
const spawned = spawnAgent(hostOpts,
|
|
1739
|
+
const spawned = spawnAgent(hostOpts, bridgeReviewEvent, (telemetry) => {
|
|
1029
1740
|
try {
|
|
1030
1741
|
// Context pressure check
|
|
1031
1742
|
if (telemetry.contextUsage) {
|
|
@@ -1090,11 +1801,17 @@ export async function executeTaskV2(
|
|
|
1090
1801
|
}
|
|
1091
1802
|
}, 1000);
|
|
1092
1803
|
|
|
1804
|
+
// Live outbox surfacing during the worker run (mail-recognition fix):
|
|
1805
|
+
// poll the worker's outbox on a timer so reply/escalate mail reaches the
|
|
1806
|
+
// supervisor mid-run, not only after the worker exits.
|
|
1807
|
+
const outboxLivePoll = setInterval(drainAndSurfaceOutbox, OUTBOX_LIVE_POLL_INTERVAL_MS);
|
|
1808
|
+
|
|
1093
1809
|
let workerResult: AgentHostResult;
|
|
1094
1810
|
try {
|
|
1095
1811
|
workerResult = await spawned.promise;
|
|
1096
1812
|
} finally {
|
|
1097
1813
|
clearInterval(reviewerRefresh);
|
|
1814
|
+
clearInterval(outboxLivePoll);
|
|
1098
1815
|
}
|
|
1099
1816
|
|
|
1100
1817
|
// TP-115: Update lastTelemetry with definitive final values from AgentHostResult
|
|
@@ -1116,70 +1833,11 @@ export async function executeTaskV2(
|
|
|
1116
1833
|
workerResult.cacheReadTokens +
|
|
1117
1834
|
workerResult.cacheWriteTokens;
|
|
1118
1835
|
|
|
1119
|
-
// ── TP-106:
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
logExecution(statusPath, `Agent ${msg.type}`, sanitized);
|
|
1125
|
-
|
|
1126
|
-
if (msg.type === "reply" || msg.type === "escalate") {
|
|
1127
|
-
appendAgentEvent(config.stateRoot, config.batchId, workerAgentId, {
|
|
1128
|
-
batchId: config.batchId,
|
|
1129
|
-
agentId: workerAgentId,
|
|
1130
|
-
role: "worker",
|
|
1131
|
-
laneNumber: config.laneNumber,
|
|
1132
|
-
taskId,
|
|
1133
|
-
repoId: config.repoId,
|
|
1134
|
-
ts: Date.now(),
|
|
1135
|
-
type: msg.type === "reply" ? "reply_sent" : "escalation_sent",
|
|
1136
|
-
payload: {
|
|
1137
|
-
messageId: msg.id,
|
|
1138
|
-
replyTo: msg.replyTo ?? null,
|
|
1139
|
-
content: sanitized,
|
|
1140
|
-
},
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
appendMailboxAuditEvent(config.stateRoot, config.batchId, {
|
|
1144
|
-
type: msg.type === "reply" ? "message_replied" : "message_escalated",
|
|
1145
|
-
from: workerAgentId,
|
|
1146
|
-
to: "supervisor",
|
|
1147
|
-
messageId: msg.id,
|
|
1148
|
-
messageType: msg.type,
|
|
1149
|
-
contentPreview: sanitized,
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
if (config.onSupervisorAlert) {
|
|
1153
|
-
const isEscalation = msg.type === "escalate";
|
|
1154
|
-
try {
|
|
1155
|
-
config.onSupervisorAlert({
|
|
1156
|
-
category: "agent-message",
|
|
1157
|
-
summary:
|
|
1158
|
-
`${isEscalation ? "🚨" : "📨"} Agent ${isEscalation ? "escalation" : "reply"} from ${workerAgentId}\n` +
|
|
1159
|
-
` Task: ${taskId}\n` +
|
|
1160
|
-
` Lane: lane-${config.laneNumber}\n` +
|
|
1161
|
-
` Message: ${sanitized}`,
|
|
1162
|
-
context: {
|
|
1163
|
-
taskId,
|
|
1164
|
-
laneId: `lane-${config.laneNumber}`,
|
|
1165
|
-
laneNumber: config.laneNumber,
|
|
1166
|
-
agentId: workerAgentId,
|
|
1167
|
-
messageId: msg.id,
|
|
1168
|
-
exitReason: `${isEscalation ? "agent_escalation" : "agent_reply"}: ${sanitized}`,
|
|
1169
|
-
},
|
|
1170
|
-
});
|
|
1171
|
-
} catch {
|
|
1172
|
-
/* best effort */
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Consume outbox message to prevent duplicate processing in later iterations.
|
|
1178
|
-
ackOutboxMessage(config.stateRoot, config.batchId, workerAgentId, msg.id);
|
|
1179
|
-
}
|
|
1180
|
-
} catch {
|
|
1181
|
-
/* best effort */
|
|
1182
|
-
}
|
|
1836
|
+
// ── TP-106 / mail-recognition: final outbox drain ────────────
|
|
1837
|
+
// Surface any reply/escalate mail written between the last live poll and
|
|
1838
|
+
// worker exit. Live-surfaced messages were already acked, so this never
|
|
1839
|
+
// double-surfaces them.
|
|
1840
|
+
drainAndSurfaceOutbox();
|
|
1183
1841
|
|
|
1184
1842
|
// ── Steering annotation ─────────────────────────────────────
|
|
1185
1843
|
try {
|
|
@@ -1187,10 +1845,34 @@ export async function executeTaskV2(
|
|
|
1187
1845
|
const raw = readFileSync(steeringPendingPath, "utf-8");
|
|
1188
1846
|
for (const line of raw.split("\n").filter((l) => l.trim())) {
|
|
1189
1847
|
try {
|
|
1190
|
-
const entry = JSON.parse(line) as { ts: number; content: string; id: string };
|
|
1848
|
+
const entry = JSON.parse(line) as { ts: number; content: string; id: string; type?: string };
|
|
1191
1849
|
const sanitized = entry.content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|").slice(0, 200);
|
|
1192
1850
|
const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
|
|
1193
1851
|
logExecution(statusPath, "⚠️ Steering", sanitized);
|
|
1852
|
+
// #630: a steer delivered AFTER the escalation counts as the ruling
|
|
1853
|
+
// (delivery, not content, is what we can observe here).
|
|
1854
|
+
// #630 contract: an `info` message is an ACKNOWLEDGEMENT ("received,
|
|
1855
|
+
// ruling pending") — the supervisor is engaged, so the relaunch counter
|
|
1856
|
+
// resets, but the worker is still holding. Any other type (steer/query/
|
|
1857
|
+
// abort) is the ruling/instruction and releases the hold. This is what
|
|
1858
|
+
// lets an hours-long operator ruling neither burn the relaunch budget
|
|
1859
|
+
// nor be mistaken for a ruling.
|
|
1860
|
+
if (entry.type === "info") {
|
|
1861
|
+
if (pendingEscalation && entry.ts >= pendingEscalation.ts) {
|
|
1862
|
+
holdRelaunches = 0;
|
|
1863
|
+
logExecution(
|
|
1864
|
+
statusPath,
|
|
1865
|
+
"Hold acknowledged",
|
|
1866
|
+
`supervisor acknowledged escalation ${pendingEscalation.id}; still holding`,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
} else {
|
|
1870
|
+
lastSupervisorReplyTs = Math.max(lastSupervisorReplyTs, entry.ts);
|
|
1871
|
+
if (pendingEscalation && entry.ts >= pendingEscalation.ts) {
|
|
1872
|
+
pendingEscalation = null;
|
|
1873
|
+
holdRelaunches = 0;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1194
1876
|
} catch {
|
|
1195
1877
|
/* skip malformed */
|
|
1196
1878
|
}
|
|
@@ -1227,6 +1909,73 @@ export async function executeTaskV2(
|
|
|
1227
1909
|
const progressDelta = afterTotalChecked - prevTotalChecked;
|
|
1228
1910
|
|
|
1229
1911
|
if (progressDelta <= 0) {
|
|
1912
|
+
// #630: a clean exit while HOLDING for a supervisor ruling is evaluated
|
|
1913
|
+
// FIRST — before the cumulative soft-progress check, which would otherwise
|
|
1914
|
+
// let pre-existing uncommitted work mask every hold exit as "progress" and
|
|
1915
|
+
// defeat the bounded relaunch contract.
|
|
1916
|
+
if (pendingEscalation && workerResult.exitCode === 0 && !workerResult.killed) {
|
|
1917
|
+
// #630: the worker exited cleanly while HOLDING for a supervisor ruling
|
|
1918
|
+
// (escalation surfaced, no steer delivered since). That is governance,
|
|
1919
|
+
// not a stall: do not count it toward the no-progress limit. Bounded by
|
|
1920
|
+
// MAX_HOLD_RELAUNCHES; each relaunch re-checks for the ruling.
|
|
1921
|
+
holdRelaunches++;
|
|
1922
|
+
logExecution(
|
|
1923
|
+
statusPath,
|
|
1924
|
+
"Hold exit",
|
|
1925
|
+
`Iteration ${totalIterations}: worker exited while awaiting a ruling for escalation ${pendingEscalation.id} (relaunch ${holdRelaunches}/${MAX_HOLD_RELAUNCHES}) — not counted toward stall`,
|
|
1926
|
+
);
|
|
1927
|
+
if (holdRelaunches > MAX_HOLD_RELAUNCHES) {
|
|
1928
|
+
const reason = `Hold unresolved: escalation ${pendingEscalation.id} ("${pendingEscalation.preview.slice(0, 120)}") received no supervisor reply across ${MAX_HOLD_RELAUNCHES} relaunches`;
|
|
1929
|
+
logExecution(statusPath, "Task blocked", reason);
|
|
1930
|
+
updateStatusField(statusPath, "Status", "⏸️ Held — ruling outstanding");
|
|
1931
|
+
if (config.onSupervisorAlert) {
|
|
1932
|
+
try {
|
|
1933
|
+
config.onSupervisorAlert({
|
|
1934
|
+
category: "task-failure",
|
|
1935
|
+
summary:
|
|
1936
|
+
`⏸️ **Hold unresolved** — ${taskId} (lane ${config.laneNumber}) escalated for a ruling ` +
|
|
1937
|
+
`(${pendingEscalation.id}: "${pendingEscalation.preview.slice(0, 160)}") and no reply reached it across ` +
|
|
1938
|
+
`${MAX_HOLD_RELAUNCHES} relaunches. The lane is stopping with the work preserved in its worktree.\n` +
|
|
1939
|
+
`Rule on the escalation, then orch_retry_task + orch_resume(force=true) to relaunch the worker ` +
|
|
1940
|
+
`(it will receive your reply as a steer at start).`,
|
|
1941
|
+
context: {
|
|
1942
|
+
taskId,
|
|
1943
|
+
laneId: `lane-${config.laneNumber}`,
|
|
1944
|
+
laneNumber: config.laneNumber,
|
|
1945
|
+
agentId: workerAgentId,
|
|
1946
|
+
messageId: pendingEscalation.id,
|
|
1947
|
+
exitReason: reason,
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1950
|
+
} catch {
|
|
1951
|
+
/* best effort */
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return makeResult(
|
|
1955
|
+
taskId,
|
|
1956
|
+
segmentId,
|
|
1957
|
+
workerAgentId,
|
|
1958
|
+
"failed",
|
|
1959
|
+
startTime,
|
|
1960
|
+
reason,
|
|
1961
|
+
false,
|
|
1962
|
+
totalIterations,
|
|
1963
|
+
cumulativeCostUsd,
|
|
1964
|
+
cumulativeTokens,
|
|
1965
|
+
config,
|
|
1966
|
+
statusPath,
|
|
1967
|
+
reviewerStatePath,
|
|
1968
|
+
lastTelemetry,
|
|
1969
|
+
snapshotSegmentCtx,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Not exhausted: this exit is accounted as a hold, not as progress or a stall,
|
|
1974
|
+
// and it does not consume a productive iteration.
|
|
1975
|
+
productiveIterations--;
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1230
1979
|
// Check for soft progress: uncommitted changes in the worktree
|
|
1231
1980
|
// indicate the worker is actively editing code even if no checkbox
|
|
1232
1981
|
// was checked yet. This avoids false stall detection on complex
|
|
@@ -1258,6 +2007,16 @@ export async function executeTaskV2(
|
|
|
1258
2007
|
`Iteration ${totalIterations}: 0 new checkboxes but uncommitted source changes detected — not counting as stall`,
|
|
1259
2008
|
);
|
|
1260
2009
|
noProgressCount = 0;
|
|
2010
|
+
} else if (remediationGates.length > 0) {
|
|
2011
|
+
// #629: a review-remediation iteration checks 0 new boxes BY DESIGN
|
|
2012
|
+
// (its output is a fresh review file, not a checkbox). It is bounded
|
|
2013
|
+
// separately by MAX_REVIEW_REMEDIATION_ITERATIONS — do not count it
|
|
2014
|
+
// toward the no-progress stall limit.
|
|
2015
|
+
logExecution(
|
|
2016
|
+
statusPath,
|
|
2017
|
+
"Remediation iteration",
|
|
2018
|
+
`Iteration ${totalIterations}: review-gate remediation — not counted toward stall`,
|
|
2019
|
+
);
|
|
1261
2020
|
} else {
|
|
1262
2021
|
noProgressCount++;
|
|
1263
2022
|
logExecution(
|
|
@@ -1324,23 +2083,43 @@ export async function executeTaskV2(
|
|
|
1324
2083
|
// Mark completed steps
|
|
1325
2084
|
// TP-174: When segment-scoped, mark step complete when the segment's
|
|
1326
2085
|
// checkboxes are all checked (not the full step which may have other segments).
|
|
2086
|
+
//
|
|
2087
|
+
// Review-gated (penster 20260906T194514 feedback #3, item 4): a step whose
|
|
2088
|
+
// LATEST review is REVISE/RETHINK must NOT be flipped to ✅ Complete just
|
|
2089
|
+
// because its checkboxes are checked — the worker correctly reverts it to
|
|
2090
|
+
// In Progress per the recovery recipe, this heuristic flipped it back on
|
|
2091
|
+
// every relaunch (a phantom uncommitted STATUS edit "never authored by the
|
|
2092
|
+
// worker"), and the flip trips review_step's complete-step guard. Same rule
|
|
2093
|
+
// as the finalize gate.
|
|
2094
|
+
const reviewBlockedSteps = new Set(
|
|
2095
|
+
findBlockingReviewGates(unit.packet.reviewsDir)
|
|
2096
|
+
.map((g) => parseGateStepNumber(g.gate))
|
|
2097
|
+
.filter((n): n is number => n !== null),
|
|
2098
|
+
);
|
|
2099
|
+
const markComplete = (stepNum: number) => {
|
|
2100
|
+
if (reviewBlockedSteps.has(stepNum)) {
|
|
2101
|
+
logExecution(
|
|
2102
|
+
statusPath,
|
|
2103
|
+
"Step completion withheld",
|
|
2104
|
+
`Step ${stepNum}: checkboxes complete but latest review is REVISE/RETHINK — not marking ✅ Complete`,
|
|
2105
|
+
);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
updateStepStatus(statusPath, stepNum, "complete");
|
|
2109
|
+
};
|
|
1327
2110
|
if (repoStepNumbers && currentRepoId) {
|
|
1328
2111
|
for (const stepNum of repoStepNumbers) {
|
|
1329
2112
|
if (isSegmentComplete(afterStatusContent, stepNum, currentRepoId)) {
|
|
1330
2113
|
// Only mark step complete in STATUS.md if ALL segments in that step
|
|
1331
2114
|
// are complete (not just ours). But for loop exit, we only care about ours.
|
|
1332
2115
|
const ss = afterStatus.steps.find((s) => s.number === stepNum);
|
|
1333
|
-
if (isStepComplete(ss))
|
|
1334
|
-
updateStepStatus(statusPath, stepNum, "complete");
|
|
1335
|
-
}
|
|
2116
|
+
if (isStepComplete(ss)) markComplete(stepNum);
|
|
1336
2117
|
}
|
|
1337
2118
|
}
|
|
1338
2119
|
} else {
|
|
1339
2120
|
for (const step of parsed.steps) {
|
|
1340
2121
|
const ss = afterStatus.steps.find((s) => s.number === step.number);
|
|
1341
|
-
if (isStepComplete(ss))
|
|
1342
|
-
updateStepStatus(statusPath, step.number, "complete");
|
|
1343
|
-
}
|
|
2122
|
+
if (isStepComplete(ss)) markComplete(step.number);
|
|
1344
2123
|
}
|
|
1345
2124
|
}
|
|
1346
2125
|
|
|
@@ -1358,7 +2137,18 @@ export async function executeTaskV2(
|
|
|
1358
2137
|
return isStepComplete(ss);
|
|
1359
2138
|
});
|
|
1360
2139
|
}
|
|
1361
|
-
if (allComplete)
|
|
2140
|
+
if (allComplete) {
|
|
2141
|
+
// #629: all boxes checked — but if a finalizing task still has an
|
|
2142
|
+
// outstanding non-APPROVE gate and remediation budget remains, loop
|
|
2143
|
+
// back so the top-of-loop check spawns a remediation iteration
|
|
2144
|
+
// instead of falling straight into the finalize refusal.
|
|
2145
|
+
// (The top-of-loop check owns the budget decision and the
|
|
2146
|
+
// "exhausted" log, so defer to it whenever a gate is outstanding.)
|
|
2147
|
+
if (!isNonFinalSegment && findBlockingReviewGates(unit.packet.reviewsDir).length > 0) {
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
break;
|
|
2151
|
+
}
|
|
1362
2152
|
}
|
|
1363
2153
|
|
|
1364
2154
|
// ── 3. Post-loop completion check ───────────────────────────────
|
|
@@ -1425,15 +2215,10 @@ export async function executeTaskV2(
|
|
|
1425
2215
|
);
|
|
1426
2216
|
}
|
|
1427
2217
|
|
|
1428
|
-
// TP-145:
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
const isNonFinalSegment =
|
|
1433
|
-
segmentId != null &&
|
|
1434
|
-
Array.isArray(unit.task.segmentIds) &&
|
|
1435
|
-
unit.task.segmentIds.length > 1 &&
|
|
1436
|
-
unit.task.segmentIds[unit.task.segmentIds.length - 1] !== segmentId;
|
|
2218
|
+
// TP-145: `isNonFinalSegment` (hoisted above the iteration loop) — if more
|
|
2219
|
+
// segments remain after this one, suppress .DONE creation so the engine can
|
|
2220
|
+
// advance the segment frontier. .DONE must only exist when ALL segments of
|
|
2221
|
+
// a multi-segment task are complete.
|
|
1437
2222
|
|
|
1438
2223
|
// TP-165: Check for pending expansion requests in the worker's outbox.
|
|
1439
2224
|
// If the worker filed expansion requests, more segments may be added by the
|
|
@@ -1496,6 +2281,95 @@ export async function executeTaskV2(
|
|
|
1496
2281
|
);
|
|
1497
2282
|
}
|
|
1498
2283
|
|
|
2284
|
+
// ── #626 minimal finalize gate: no .DONE over an outstanding REVISE ───
|
|
2285
|
+
// Two live incidents merged unreviewed code: a worker self-released past a
|
|
2286
|
+
// REVISE cap and wrote .DONE (TP-2037), and this very checkbox heuristic
|
|
2287
|
+
// wrote .DONE for a correctly-holding worker (TP-2039). The gate: for each
|
|
2288
|
+
// review gate ({type}-step{N}), the LATEST review file's verdict must not be
|
|
2289
|
+
// REVISE/RETHINK. A re-review (higher R number) with APPROVE — or an
|
|
2290
|
+
// operator ratification recorded as the next R-numbered review file — clears
|
|
2291
|
+
// it. Steps with no reviews at all are not blocked here (full coverage gate
|
|
2292
|
+
// is #626's designed follow-up).
|
|
2293
|
+
const blockingGates = findBlockingReviewGates(unit.packet.reviewsDir);
|
|
2294
|
+
|
|
2295
|
+
if (blockingGates.length > 0) {
|
|
2296
|
+
// Remove any worker-written .DONE (precedent: premature-.DONE removal in
|
|
2297
|
+
// the non-final-segment path above).
|
|
2298
|
+
if (existsSync(donePath)) {
|
|
2299
|
+
try {
|
|
2300
|
+
unlinkSync(donePath);
|
|
2301
|
+
} catch {
|
|
2302
|
+
/* best effort */
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const gateList = formatBlockingGates(blockingGates);
|
|
2306
|
+
logExecution(
|
|
2307
|
+
statusPath,
|
|
2308
|
+
"Finalize refused",
|
|
2309
|
+
`Review gate: latest verdict is not APPROVE — ${gateList}`,
|
|
2310
|
+
);
|
|
2311
|
+
if (config.onSupervisorAlert) {
|
|
2312
|
+
try {
|
|
2313
|
+
config.onSupervisorAlert({
|
|
2314
|
+
category: "review-intervention-needed",
|
|
2315
|
+
summary:
|
|
2316
|
+
`⛔ **Finalize refused** — ${taskId} (lane ${config.laneNumber}) attempted to ` +
|
|
2317
|
+
`complete with an outstanding non-APPROVE review: ${gateList}.\n` +
|
|
2318
|
+
`The task is marked failed instead of finalizing over the unresolved verdict. ` +
|
|
2319
|
+
`Adjudicate: have the worker address the findings and re-run review_step ` +
|
|
2320
|
+
`(orch_retry_task + orch_resume), or record an operator ratification as the ` +
|
|
2321
|
+
`next R-numbered review file with an explicit APPROVE verdict.`,
|
|
2322
|
+
context: {
|
|
2323
|
+
taskId,
|
|
2324
|
+
laneId: `lane-${config.laneNumber}`,
|
|
2325
|
+
laneNumber: config.laneNumber,
|
|
2326
|
+
agentId: workerAgentId,
|
|
2327
|
+
reviewInterventionKind: "unresolved-verdict",
|
|
2328
|
+
exitReason: `finalize refused: ${gateList}`,
|
|
2329
|
+
},
|
|
2330
|
+
});
|
|
2331
|
+
} catch {
|
|
2332
|
+
/* best effort */
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
const refusal = makeResult(
|
|
2336
|
+
taskId,
|
|
2337
|
+
segmentId,
|
|
2338
|
+
workerAgentId,
|
|
2339
|
+
"failed",
|
|
2340
|
+
startTime,
|
|
2341
|
+
`Review gate: cannot finalize — latest review verdict is not APPROVE (${gateList})`,
|
|
2342
|
+
false,
|
|
2343
|
+
totalIterations,
|
|
2344
|
+
cumulativeCostUsd,
|
|
2345
|
+
cumulativeTokens,
|
|
2346
|
+
config,
|
|
2347
|
+
statusPath,
|
|
2348
|
+
reviewerStatePath,
|
|
2349
|
+
lastTelemetry,
|
|
2350
|
+
snapshotSegmentCtx,
|
|
2351
|
+
);
|
|
2352
|
+
// #629 side-effect 1: a governance refusal is NOT a crash. Attach a
|
|
2353
|
+
// structured diagnostic so tier-0 auto-retry, reports and the dashboard
|
|
2354
|
+
// can tell it apart (the worker exited cleanly; the review file must
|
|
2355
|
+
// change before a retry can succeed).
|
|
2356
|
+
const refusalDiagnostic: TaskExitDiagnostic = {
|
|
2357
|
+
classification: "review_gate_refusal",
|
|
2358
|
+
exitCode: 0,
|
|
2359
|
+
errorMessage: `finalize refused: ${gateList}`,
|
|
2360
|
+
tokensUsed: null,
|
|
2361
|
+
contextPct: null,
|
|
2362
|
+
partialProgressCommits: 0,
|
|
2363
|
+
partialProgressBranch: null,
|
|
2364
|
+
durationSec: Math.round((Date.now() - startTime) / 1000),
|
|
2365
|
+
lastKnownStep: null,
|
|
2366
|
+
lastKnownCheckbox: null,
|
|
2367
|
+
repoId: config.repoId ?? "default",
|
|
2368
|
+
};
|
|
2369
|
+
refusal.outcome.exitDiagnostic = refusalDiagnostic;
|
|
2370
|
+
return refusal;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
1499
2373
|
// Create .DONE if not already present (final segment or single-segment/whole-task execution)
|
|
1500
2374
|
if (!existsSync(donePath)) {
|
|
1501
2375
|
writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${taskId}\n`);
|
|
@@ -1553,6 +2427,8 @@ export function mapLaneTaskStatusToTerminalSnapshotStatus(
|
|
|
1553
2427
|
): "idle" | "complete" | "failed" {
|
|
1554
2428
|
if (status === "succeeded") return "complete";
|
|
1555
2429
|
if (status === "skipped") return "idle";
|
|
2430
|
+
// A paused (pending) task is not a failure — the lane is idle awaiting resume.
|
|
2431
|
+
if (status === "pending") return "idle";
|
|
1556
2432
|
return "failed";
|
|
1557
2433
|
}
|
|
1558
2434
|
|