taskplane 0.10.2 → 0.12.0
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/README.md +5 -5
- package/dashboard/server.cjs +4 -0
- package/extensions/task-runner.ts +58 -18
- package/extensions/taskplane/config-loader.ts +4 -0
- package/extensions/taskplane/config-schema.ts +24 -0
- package/extensions/taskplane/diagnostics.ts +75 -13
- package/extensions/taskplane/engine.ts +335 -13
- package/extensions/taskplane/execution.ts +6 -1
- package/extensions/taskplane/merge.ts +358 -2
- package/extensions/taskplane/supervisor-primer.md +30 -0
- package/extensions/taskplane/supervisor.ts +23 -0
- package/extensions/taskplane/types.ts +123 -1
- package/package.json +1 -1
|
@@ -8,12 +8,13 @@ import { join, dirname, resolve, relative } from "path";
|
|
|
8
8
|
|
|
9
9
|
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
10
|
import { resolveOperatorId } from "./naming.ts";
|
|
11
|
-
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
12
|
-
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
11
|
+
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MERGE_HEALTH_CAPTURE_LINES, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
|
|
12
|
+
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
|
|
13
13
|
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
14
14
|
import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
|
|
15
15
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
16
16
|
import { ORCH_MESSAGES } from "./messages.ts";
|
|
17
|
+
import { emitEngineEvent } from "./persistence.ts";
|
|
17
18
|
import { loadOrchestratorConfig } from "./config.ts";
|
|
18
19
|
import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
|
|
19
20
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
@@ -1019,6 +1020,7 @@ export async function mergeWave(
|
|
|
1019
1020
|
agentRoot?: string,
|
|
1020
1021
|
testingCommands?: Record<string, string>,
|
|
1021
1022
|
repoId?: string,
|
|
1023
|
+
healthMonitor?: MergeHealthMonitor | null,
|
|
1022
1024
|
): Promise<MergeWaveResult> {
|
|
1023
1025
|
const startTime = Date.now();
|
|
1024
1026
|
const tmuxPrefix = config.orchestrator.tmux_prefix;
|
|
@@ -1319,13 +1321,19 @@ export async function mergeWave(
|
|
|
1319
1321
|
|
|
1320
1322
|
// Re-spawn merge agent for the retry
|
|
1321
1323
|
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
|
|
1324
|
+
// TP-056: Re-register with health monitor after respawn
|
|
1325
|
+
if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1322
1326
|
} else {
|
|
1323
1327
|
// First attempt: spawn merge agent
|
|
1324
1328
|
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
|
|
1329
|
+
// TP-056: Register session with health monitor
|
|
1330
|
+
if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1325
1331
|
}
|
|
1326
1332
|
|
|
1327
1333
|
try {
|
|
1328
1334
|
mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs);
|
|
1335
|
+
// TP-056: Deregister session from health monitor on completion
|
|
1336
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1329
1337
|
lastTimeoutError = null;
|
|
1330
1338
|
break; // Success — exit retry loop
|
|
1331
1339
|
} catch (waitErr: unknown) {
|
|
@@ -1336,9 +1344,13 @@ export async function mergeWave(
|
|
|
1336
1344
|
) {
|
|
1337
1345
|
// Timeout — will retry on next loop iteration
|
|
1338
1346
|
lastTimeoutError = waitErr;
|
|
1347
|
+
// TP-056: Deregister before retry (will re-register on respawn)
|
|
1348
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1339
1349
|
continue;
|
|
1340
1350
|
}
|
|
1341
1351
|
// Non-timeout error or final retry exhausted — propagate
|
|
1352
|
+
// TP-056: Deregister session from health monitor on error
|
|
1353
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1342
1354
|
throw waitErr;
|
|
1343
1355
|
}
|
|
1344
1356
|
}
|
|
@@ -1933,6 +1945,7 @@ export async function mergeWaveByRepo(
|
|
|
1933
1945
|
stateRoot?: string,
|
|
1934
1946
|
agentRoot?: string,
|
|
1935
1947
|
testingCommands?: Record<string, string>,
|
|
1948
|
+
healthMonitor?: MergeHealthMonitor | null,
|
|
1936
1949
|
): Promise<MergeWaveResult> {
|
|
1937
1950
|
const startTime = Date.now();
|
|
1938
1951
|
|
|
@@ -1988,6 +2001,8 @@ export async function mergeWaveByRepo(
|
|
|
1988
2001
|
stateRoot,
|
|
1989
2002
|
agentRoot,
|
|
1990
2003
|
testingCommands,
|
|
2004
|
+
undefined, // repoId
|
|
2005
|
+
healthMonitor,
|
|
1991
2006
|
);
|
|
1992
2007
|
// Attach empty repoResults for consistent shape
|
|
1993
2008
|
return { ...result, repoResults: [] };
|
|
@@ -2044,6 +2059,7 @@ export async function mergeWaveByRepo(
|
|
|
2044
2059
|
agentRoot,
|
|
2045
2060
|
testingCommands,
|
|
2046
2061
|
group.repoId,
|
|
2062
|
+
healthMonitor,
|
|
2047
2063
|
);
|
|
2048
2064
|
|
|
2049
2065
|
// Accumulate lane results
|
|
@@ -2154,6 +2170,8 @@ export async function mergeWaveByRepo(
|
|
|
2154
2170
|
return aggregateResult;
|
|
2155
2171
|
}
|
|
2156
2172
|
|
|
2173
|
+
|
|
2174
|
+
|
|
2157
2175
|
// ── Auto-Integration ─────────────────────────────────────────────────
|
|
2158
2176
|
|
|
2159
2177
|
/**
|
|
@@ -2256,3 +2274,341 @@ export function attemptAutoIntegration(
|
|
|
2256
2274
|
return true;
|
|
2257
2275
|
}
|
|
2258
2276
|
|
|
2277
|
+
// ── Merge Health Monitor (TP-056) ────────────────────────────────────
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Capture the last N lines of a tmux pane for activity detection.
|
|
2281
|
+
*
|
|
2282
|
+
* Uses `tmux capture-pane` with `-p` (stdout) and `-S -N` (last N lines).
|
|
2283
|
+
* Returns null if the session doesn't exist or capture fails.
|
|
2284
|
+
*
|
|
2285
|
+
* @param sessionName - TMUX session name
|
|
2286
|
+
* @param lines - Number of lines to capture from the bottom
|
|
2287
|
+
* @returns Captured text, or null on failure
|
|
2288
|
+
*
|
|
2289
|
+
* @since TP-056
|
|
2290
|
+
*/
|
|
2291
|
+
export function captureMergePaneOutput(
|
|
2292
|
+
sessionName: string,
|
|
2293
|
+
lines: number = MERGE_HEALTH_CAPTURE_LINES,
|
|
2294
|
+
): string | null {
|
|
2295
|
+
try {
|
|
2296
|
+
const result = spawnSync("tmux", [
|
|
2297
|
+
"capture-pane",
|
|
2298
|
+
"-t", sessionName,
|
|
2299
|
+
"-p", // print to stdout
|
|
2300
|
+
"-S", `-${lines}`, // last N lines
|
|
2301
|
+
], { encoding: "utf-8", timeout: 5_000 });
|
|
2302
|
+
|
|
2303
|
+
if (result.status !== 0) {
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
return result.stdout ?? null;
|
|
2308
|
+
} catch {
|
|
2309
|
+
return null;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Classify the health of a merge session based on session liveness
|
|
2315
|
+
* and pane output activity.
|
|
2316
|
+
*
|
|
2317
|
+
* Pure function — no side effects. Takes the current state and produces
|
|
2318
|
+
* a health classification.
|
|
2319
|
+
*
|
|
2320
|
+
* @param sessionAlive - Whether the tmux session is alive
|
|
2321
|
+
* @param hasResultFile - Whether the merge result file exists
|
|
2322
|
+
* @param currentOutput - Current pane capture (null if session dead or capture failed)
|
|
2323
|
+
* @param healthState - Tracked health state for this session
|
|
2324
|
+
* @param now - Current epoch ms
|
|
2325
|
+
* @returns Updated health status
|
|
2326
|
+
*
|
|
2327
|
+
* @since TP-056
|
|
2328
|
+
*/
|
|
2329
|
+
export function classifyMergeHealth(
|
|
2330
|
+
sessionAlive: boolean,
|
|
2331
|
+
hasResultFile: boolean,
|
|
2332
|
+
currentOutput: string | null,
|
|
2333
|
+
healthState: MergeSessionHealthState,
|
|
2334
|
+
now: number,
|
|
2335
|
+
): MergeHealthStatus {
|
|
2336
|
+
// Dead session with no result file → immediate detection
|
|
2337
|
+
if (!sessionAlive && !hasResultFile) {
|
|
2338
|
+
return "dead";
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Session dead but result file exists → merge completed, healthy
|
|
2342
|
+
if (!sessionAlive && hasResultFile) {
|
|
2343
|
+
return "healthy";
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// Session alive — check activity
|
|
2347
|
+
const lastContent = healthState.lastSnapshot?.content ?? null;
|
|
2348
|
+
const outputChanged = currentOutput !== null
|
|
2349
|
+
&& (lastContent === null || currentOutput !== lastContent);
|
|
2350
|
+
|
|
2351
|
+
if (outputChanged) {
|
|
2352
|
+
return "healthy";
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// No output change — compute stale duration
|
|
2356
|
+
const staleDuration = now - healthState.lastActivityAt;
|
|
2357
|
+
|
|
2358
|
+
if (staleDuration >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
|
|
2359
|
+
return "stuck";
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (staleDuration >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
|
|
2363
|
+
return "warning";
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
return "healthy";
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Active merge session health monitor.
|
|
2371
|
+
*
|
|
2372
|
+
* Runs on its own polling interval during the merge phase, checking each
|
|
2373
|
+
* active merge session for liveness and activity. Emits structured events
|
|
2374
|
+
* for the supervisor to consume.
|
|
2375
|
+
*
|
|
2376
|
+
* Design principles (from PROMPT.md):
|
|
2377
|
+
* - Does NOT kill sessions autonomously — emits events for operator decision
|
|
2378
|
+
* - Runs independently of the merge result poll
|
|
2379
|
+
* - Stores session snapshots in memory (ephemeral, not persisted)
|
|
2380
|
+
* - Emits structured events to the unified events.jsonl
|
|
2381
|
+
*
|
|
2382
|
+
* @since TP-056
|
|
2383
|
+
*/
|
|
2384
|
+
export class MergeHealthMonitor {
|
|
2385
|
+
/** Per-session health state, keyed by session name */
|
|
2386
|
+
private sessions: Map<string, MergeSessionHealthState> = new Map();
|
|
2387
|
+
|
|
2388
|
+
/** Timer handle for the polling loop */
|
|
2389
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
2390
|
+
|
|
2391
|
+
/** Whether the monitor is currently running */
|
|
2392
|
+
private _running = false;
|
|
2393
|
+
|
|
2394
|
+
/** Callback invoked when a dead session is detected (for early exit signaling) */
|
|
2395
|
+
private _onDeadSession: ((sessionName: string, laneNumber: number) => void) | null = null;
|
|
2396
|
+
|
|
2397
|
+
/** Event emission context */
|
|
2398
|
+
private stateRoot: string;
|
|
2399
|
+
private batchId: string;
|
|
2400
|
+
private waveIndex: number;
|
|
2401
|
+
private phase: OrchBatchPhase;
|
|
2402
|
+
|
|
2403
|
+
/** Polling interval override (for testing) */
|
|
2404
|
+
private pollIntervalMs: number;
|
|
2405
|
+
|
|
2406
|
+
constructor(opts: {
|
|
2407
|
+
stateRoot: string;
|
|
2408
|
+
batchId: string;
|
|
2409
|
+
waveIndex: number;
|
|
2410
|
+
phase: OrchBatchPhase;
|
|
2411
|
+
pollIntervalMs?: number;
|
|
2412
|
+
onDeadSession?: (sessionName: string, laneNumber: number) => void;
|
|
2413
|
+
}) {
|
|
2414
|
+
this.stateRoot = opts.stateRoot;
|
|
2415
|
+
this.batchId = opts.batchId;
|
|
2416
|
+
this.waveIndex = opts.waveIndex;
|
|
2417
|
+
this.phase = opts.phase;
|
|
2418
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? MERGE_HEALTH_POLL_INTERVAL_MS;
|
|
2419
|
+
this._onDeadSession = opts.onDeadSession ?? null;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** Whether the monitor is currently running */
|
|
2423
|
+
get running(): boolean {
|
|
2424
|
+
return this._running;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Register a merge session for monitoring.
|
|
2429
|
+
*
|
|
2430
|
+
* @param sessionName - TMUX session name
|
|
2431
|
+
* @param laneNumber - Lane number the session belongs to
|
|
2432
|
+
* @param resultPath - Path to the expected merge result file
|
|
2433
|
+
*/
|
|
2434
|
+
addSession(sessionName: string, laneNumber: number, resultPath: string): void {
|
|
2435
|
+
const now = Date.now();
|
|
2436
|
+
this.sessions.set(sessionName, {
|
|
2437
|
+
sessionName,
|
|
2438
|
+
laneNumber,
|
|
2439
|
+
lastSnapshot: null,
|
|
2440
|
+
lastActivityAt: now,
|
|
2441
|
+
status: "healthy",
|
|
2442
|
+
warningEmitted: false,
|
|
2443
|
+
stuckEmitted: false,
|
|
2444
|
+
deadEmitted: false,
|
|
2445
|
+
});
|
|
2446
|
+
// Store resultPath for later lookup
|
|
2447
|
+
this._resultPaths.set(sessionName, resultPath);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/** Result file paths for each session (for dead-session detection) */
|
|
2451
|
+
private _resultPaths: Map<string, string> = new Map();
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* Remove a session from monitoring (e.g., merge completed for this lane).
|
|
2455
|
+
*/
|
|
2456
|
+
removeSession(sessionName: string): void {
|
|
2457
|
+
this.sessions.delete(sessionName);
|
|
2458
|
+
this._resultPaths.delete(sessionName);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/**
|
|
2462
|
+
* Start the health monitoring polling loop.
|
|
2463
|
+
*/
|
|
2464
|
+
start(): void {
|
|
2465
|
+
if (this._running) return;
|
|
2466
|
+
this._running = true;
|
|
2467
|
+
|
|
2468
|
+
execLog("merge-health", "monitor", "merge health monitor started", {
|
|
2469
|
+
sessionCount: this.sessions.size,
|
|
2470
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
2471
|
+
});
|
|
2472
|
+
|
|
2473
|
+
this.pollTimer = setInterval(() => {
|
|
2474
|
+
this.poll();
|
|
2475
|
+
}, this.pollIntervalMs);
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* Stop the health monitoring polling loop.
|
|
2480
|
+
*/
|
|
2481
|
+
stop(): void {
|
|
2482
|
+
if (!this._running) return;
|
|
2483
|
+
this._running = false;
|
|
2484
|
+
|
|
2485
|
+
if (this.pollTimer !== null) {
|
|
2486
|
+
clearInterval(this.pollTimer);
|
|
2487
|
+
this.pollTimer = null;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
execLog("merge-health", "monitor", "merge health monitor stopped", {
|
|
2491
|
+
sessionCount: this.sessions.size,
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
this.sessions.clear();
|
|
2495
|
+
this._resultPaths.clear();
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
/**
|
|
2499
|
+
* Run a single poll cycle across all monitored sessions.
|
|
2500
|
+
*
|
|
2501
|
+
* Exposed as public for testing — normally called by the interval timer.
|
|
2502
|
+
*/
|
|
2503
|
+
poll(): void {
|
|
2504
|
+
const now = Date.now();
|
|
2505
|
+
|
|
2506
|
+
for (const [sessionName, state] of this.sessions) {
|
|
2507
|
+
const sessionAlive = tmuxHasSession(sessionName);
|
|
2508
|
+
const resultPath = this._resultPaths.get(sessionName) ?? "";
|
|
2509
|
+
const hasResultFile = resultPath ? existsSync(resultPath) : false;
|
|
2510
|
+
|
|
2511
|
+
// Capture pane output for activity detection
|
|
2512
|
+
const currentOutput = sessionAlive
|
|
2513
|
+
? captureMergePaneOutput(sessionName)
|
|
2514
|
+
: null;
|
|
2515
|
+
|
|
2516
|
+
// Classify health
|
|
2517
|
+
const newStatus = classifyMergeHealth(
|
|
2518
|
+
sessionAlive,
|
|
2519
|
+
hasResultFile,
|
|
2520
|
+
currentOutput,
|
|
2521
|
+
state,
|
|
2522
|
+
now,
|
|
2523
|
+
);
|
|
2524
|
+
|
|
2525
|
+
// Update snapshot if output changed
|
|
2526
|
+
if (currentOutput !== null && (
|
|
2527
|
+
state.lastSnapshot === null || currentOutput !== state.lastSnapshot.content
|
|
2528
|
+
)) {
|
|
2529
|
+
state.lastSnapshot = { content: currentOutput, capturedAt: now };
|
|
2530
|
+
state.lastActivityAt = now;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
const prevStatus = state.status;
|
|
2534
|
+
state.status = newStatus;
|
|
2535
|
+
|
|
2536
|
+
// Emit events based on status transitions
|
|
2537
|
+
this._emitHealthEvents(state, now);
|
|
2538
|
+
|
|
2539
|
+
// Signal dead session for early exit
|
|
2540
|
+
if (newStatus === "dead" && !state.deadEmitted) {
|
|
2541
|
+
state.deadEmitted = true;
|
|
2542
|
+
if (this._onDeadSession) {
|
|
2543
|
+
this._onDeadSession(sessionName, state.laneNumber);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* Emit health events based on current state.
|
|
2551
|
+
* De-duplicates: each event type emitted at most once per session.
|
|
2552
|
+
*/
|
|
2553
|
+
private _emitHealthEvents(state: MergeSessionHealthState, now: number): void {
|
|
2554
|
+
const stalledMinutes = Math.round((now - state.lastActivityAt) / 60_000);
|
|
2555
|
+
|
|
2556
|
+
if (state.status === "warning" && !state.warningEmitted) {
|
|
2557
|
+
state.warningEmitted = true;
|
|
2558
|
+
const event: EngineEvent = {
|
|
2559
|
+
...buildEngineEventBase("merge_health_warning", this.batchId, this.waveIndex, this.phase),
|
|
2560
|
+
laneNumber: state.laneNumber,
|
|
2561
|
+
sessionName: state.sessionName,
|
|
2562
|
+
healthStatus: "warning",
|
|
2563
|
+
stalledMinutes,
|
|
2564
|
+
reason: `Merge agent on lane ${state.laneNumber} may be stalled (no output for ${stalledMinutes} min)`,
|
|
2565
|
+
};
|
|
2566
|
+
emitEngineEvent(this.stateRoot, event);
|
|
2567
|
+
execLog("merge-health", state.sessionName, `⚠️ merge session possibly stalled`, {
|
|
2568
|
+
stalledMinutes,
|
|
2569
|
+
laneNumber: state.laneNumber,
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (state.status === "dead" && !state.deadEmitted) {
|
|
2574
|
+
// deadEmitted is set in poll() after onDeadSession callback
|
|
2575
|
+
const event: EngineEvent = {
|
|
2576
|
+
...buildEngineEventBase("merge_health_dead", this.batchId, this.waveIndex, this.phase),
|
|
2577
|
+
laneNumber: state.laneNumber,
|
|
2578
|
+
sessionName: state.sessionName,
|
|
2579
|
+
healthStatus: "dead",
|
|
2580
|
+
reason: `Merge agent on lane ${state.laneNumber} session died without producing a result`,
|
|
2581
|
+
};
|
|
2582
|
+
emitEngineEvent(this.stateRoot, event);
|
|
2583
|
+
execLog("merge-health", state.sessionName, `💀 merge session dead — no result file`, {
|
|
2584
|
+
laneNumber: state.laneNumber,
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
if (state.status === "stuck" && !state.stuckEmitted) {
|
|
2589
|
+
state.stuckEmitted = true;
|
|
2590
|
+
const event: EngineEvent = {
|
|
2591
|
+
...buildEngineEventBase("merge_health_stuck", this.batchId, this.waveIndex, this.phase),
|
|
2592
|
+
laneNumber: state.laneNumber,
|
|
2593
|
+
sessionName: state.sessionName,
|
|
2594
|
+
healthStatus: "stuck",
|
|
2595
|
+
stalledMinutes,
|
|
2596
|
+
reason: `Merge agent on lane ${state.laneNumber} appears stuck (no output for ${stalledMinutes} min). Consider killing and retrying.`,
|
|
2597
|
+
};
|
|
2598
|
+
emitEngineEvent(this.stateRoot, event);
|
|
2599
|
+
execLog("merge-health", state.sessionName, `🔒 merge session stuck`, {
|
|
2600
|
+
stalledMinutes,
|
|
2601
|
+
laneNumber: state.laneNumber,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/**
|
|
2607
|
+
* Get the current health states for all monitored sessions.
|
|
2608
|
+
* Used for testing and inspection.
|
|
2609
|
+
*/
|
|
2610
|
+
getSessionStates(): Map<string, MergeSessionHealthState> {
|
|
2611
|
+
return new Map(this.sessions);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
@@ -189,6 +189,8 @@ detects the sessionId mismatch and yields gracefully.
|
|
|
189
189
|
|
|
190
190
|
Engine lifecycle events (wave_start, task_complete, merge_success, etc.)
|
|
191
191
|
are written here as JSONL. You tail this file for proactive monitoring.
|
|
192
|
+
Merge health monitoring events (merge_health_warning, merge_health_dead,
|
|
193
|
+
merge_health_stuck) are also written here when merge agents stall or die.
|
|
192
194
|
|
|
193
195
|
**Audit trail:** `.pi/supervisor/actions.jsonl`
|
|
194
196
|
|
|
@@ -244,6 +246,8 @@ Wave N starts
|
|
|
244
246
|
| Execute | Worker makes no progress | STATUS.md unchanged for `stallTimeout` minutes |
|
|
245
247
|
| Execute | API error (rate limit, overload) | Session exits, pi handles retry internally |
|
|
246
248
|
| Merge | Merge agent times out | No result JSON within `merge.timeoutMinutes` |
|
|
249
|
+
| Merge | Merge agent stalls silently | `merge_health_warning` or `merge_health_stuck` event |
|
|
250
|
+
| Merge | Merge agent session dies | `merge_health_dead` event — no result file |
|
|
247
251
|
| Merge | Merge conflicts too complex | Merge agent can't resolve |
|
|
248
252
|
| Merge | Verification tests fail | Tests fail in merge worktree |
|
|
249
253
|
| Cleanup | Windows file locks | `git worktree remove` fails |
|
|
@@ -324,6 +328,32 @@ git log --oneline orch/{branch}..task/{lane-branch} # empty = already merged
|
|
|
324
328
|
```
|
|
325
329
|
5. Update batch state and advance.
|
|
326
330
|
|
|
331
|
+
### Pattern 1b: Merge Agent Stall (TP-056)
|
|
332
|
+
|
|
333
|
+
**Symptom:** Supervisor notification: "⚠️ Merge agent on lane N may be stalled (no output for 10 min)"
|
|
334
|
+
or "🔒 Merge agent on lane N appears stuck (no output for 20 min)."
|
|
335
|
+
|
|
336
|
+
**How it works:** The merge health monitor (TP-056) actively polls merge agent
|
|
337
|
+
tmux sessions every 2 minutes during the merge phase. It checks:
|
|
338
|
+
- **Session liveness:** `tmux has-session` — is the session alive?
|
|
339
|
+
- **Activity detection:** Captures the last 10 lines of pane output and compares
|
|
340
|
+
with the previous snapshot. If output hasn't changed, the session may be stalled.
|
|
341
|
+
|
|
342
|
+
**Escalation tiers:**
|
|
343
|
+
- **Healthy:** Session alive, output changing → no action
|
|
344
|
+
- **Warning** (10 min no output): `merge_health_warning` event → supervisor notification
|
|
345
|
+
- **Dead** (session gone, no result file): `merge_health_dead` event → immediate detection
|
|
346
|
+
- **Stuck** (20 min no output): `merge_health_stuck` event → recommendation to kill
|
|
347
|
+
|
|
348
|
+
**Recovery:**
|
|
349
|
+
1. Attach to the session to inspect: `tmux attach -t {sessionName}`
|
|
350
|
+
2. If truly stuck, kill the session: `tmux kill-session -t {sessionName}`
|
|
351
|
+
3. The engine detects the dead session and applies the `on_merge_failure` policy
|
|
352
|
+
4. Resume with `/orch-resume` if needed
|
|
353
|
+
|
|
354
|
+
**Note:** The monitor does NOT kill sessions autonomously — it emits events for
|
|
355
|
+
the operator or supervisor to decide.
|
|
356
|
+
|
|
327
357
|
### Pattern 2: Resume Skips Wave Merge (Bug #102)
|
|
328
358
|
|
|
329
359
|
**Symptom:** After `/orch-resume`, the engine says "wave N: no tasks to execute
|
|
@@ -3212,6 +3212,10 @@ interface ParsedEvent {
|
|
|
3212
3212
|
skippedTasks?: number;
|
|
3213
3213
|
blockedTasks?: number;
|
|
3214
3214
|
batchDurationMs?: number;
|
|
3215
|
+
// ── Merge health monitoring fields (TP-056) ─────────────────
|
|
3216
|
+
sessionName?: string;
|
|
3217
|
+
healthStatus?: string;
|
|
3218
|
+
stalledMinutes?: number;
|
|
3215
3219
|
// ── Tier0Event-specific optional fields ──────────────────────
|
|
3216
3220
|
pattern?: string;
|
|
3217
3221
|
attempt?: number;
|
|
@@ -3239,6 +3243,9 @@ const SIGNIFICANT_EVENT_TYPES = new Set<UnifiedEventType>([
|
|
|
3239
3243
|
"merge_start",
|
|
3240
3244
|
"merge_success",
|
|
3241
3245
|
"merge_failed",
|
|
3246
|
+
"merge_health_warning",
|
|
3247
|
+
"merge_health_dead",
|
|
3248
|
+
"merge_health_stuck",
|
|
3242
3249
|
"batch_complete",
|
|
3243
3250
|
"batch_paused",
|
|
3244
3251
|
"tier0_escalation",
|
|
@@ -3477,6 +3484,20 @@ export function formatEventNotification(
|
|
|
3477
3484
|
return `⚠️ **Wave ${waveNum} merge failed**${laneInfo}: ${reason}.\n` +
|
|
3478
3485
|
` Recovery may be needed. Check the merge logs for details.`;
|
|
3479
3486
|
}
|
|
3487
|
+
case "merge_health_warning": {
|
|
3488
|
+
const lane = event.laneNumber !== undefined ? event.laneNumber : "?";
|
|
3489
|
+
const mins = event.stalledMinutes ?? "?";
|
|
3490
|
+
return `⚠️ Merge agent on lane ${lane} may be stalled (no output for ${mins} min)`;
|
|
3491
|
+
}
|
|
3492
|
+
case "merge_health_dead": {
|
|
3493
|
+
const lane = event.laneNumber !== undefined ? event.laneNumber : "?";
|
|
3494
|
+
return `💀 Merge agent on lane ${lane} session died — triggering early retry`;
|
|
3495
|
+
}
|
|
3496
|
+
case "merge_health_stuck": {
|
|
3497
|
+
const lane = event.laneNumber !== undefined ? event.laneNumber : "?";
|
|
3498
|
+
const mins = event.stalledMinutes ?? "?";
|
|
3499
|
+
return `🔒 Merge agent on lane ${lane} appears stuck (no output for ${mins} min). Consider killing and retrying.`;
|
|
3500
|
+
}
|
|
3480
3501
|
case "batch_complete": {
|
|
3481
3502
|
const parts: string[] = [];
|
|
3482
3503
|
if (event.succeededTasks !== undefined) parts.push(`${event.succeededTasks} succeeded`);
|
|
@@ -3598,6 +3619,8 @@ export function shouldNotify(
|
|
|
3598
3619
|
eventType === "batch_complete" ||
|
|
3599
3620
|
eventType === "batch_paused" ||
|
|
3600
3621
|
eventType === "merge_failed" ||
|
|
3622
|
+
eventType === "merge_health_dead" ||
|
|
3623
|
+
eventType === "merge_health_stuck" ||
|
|
3601
3624
|
eventType === "tier0_escalation"
|
|
3602
3625
|
) {
|
|
3603
3626
|
return true;
|