@zhuxixi/pi-agent-board 0.6.2 → 0.7.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/CHANGELOG.md +17 -0
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/package.json +1 -1
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +370 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +69 -31
- package/runner/state-coordinator.mjs +403 -0
- package/runner/state-runner.mjs +89 -15
- package/src/commands/bg.ts +2 -1
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/paths.mjs +16 -0
- package/src/core/pty-attach-jiggle-controller.mjs +57 -4
- package/src/core/pty-attach-render.mjs +30 -0
- package/src/core/state-commands.mjs +617 -0
- package/src/core/types.mjs +2 -0
- package/src/runtime/service.mjs +416 -111
- package/src/ui/dashboard.ts +77 -110
- package/src/ui/pty-attach.ts +63 -1
package/src/runtime/service.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { randomBytes } from "node:crypto";
|
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import { createConnection } from "node:net";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
|
-
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState } from "../core/auto-state.mjs";
|
|
12
|
+
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState, isManualCompletion } from "../core/auto-state.mjs";
|
|
13
13
|
import { appendLine, atomicWriteJson, removeFile } from "../core/atomic.mjs";
|
|
14
14
|
import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
|
|
15
15
|
import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
|
|
@@ -27,6 +27,7 @@ import { isAlive, killProcess } from "../core/pid.mjs";
|
|
|
27
27
|
import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
|
|
28
28
|
import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
|
|
29
29
|
import { modelRefAvailable } from "../core/launch-options.mjs";
|
|
30
|
+
import { sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
|
|
30
31
|
import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
|
|
31
32
|
import * as P from "../core/paths.mjs";
|
|
32
33
|
import {
|
|
@@ -88,6 +89,7 @@ const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
|
|
|
88
89
|
* signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
|
|
89
90
|
* probeHostFn?: typeof probeHost,
|
|
90
91
|
* sleepFn?: (ms: number) => Promise<void>,
|
|
92
|
+
* sendStateCommand?: typeof sendStateCommand,
|
|
91
93
|
* availableModels?: () => Array<{ provider: string, id: string }> | undefined,
|
|
92
94
|
* }} opts
|
|
93
95
|
*/
|
|
@@ -104,6 +106,7 @@ export function createService(opts) {
|
|
|
104
106
|
const nowImpl = opts.now ?? Date.now;
|
|
105
107
|
const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
|
|
106
108
|
const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
|
|
109
|
+
const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
|
|
107
110
|
// Identity-aware observation/signalling for host recovery (issue #70). Callers must
|
|
108
111
|
// only signal after observeProcess returned "owned" for that exact identity.
|
|
109
112
|
const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
|
|
@@ -146,7 +149,7 @@ export function createService(opts) {
|
|
|
146
149
|
* @param {RunKind} kind
|
|
147
150
|
* @returns {{ runId: string, pid: number|null }}
|
|
148
151
|
*/
|
|
149
|
-
function launchForView(meta, prompt, kind) {
|
|
152
|
+
async function launchForView(meta, prompt, kind) {
|
|
150
153
|
const runId = newRunId();
|
|
151
154
|
/** @type {import("../core/types.mjs").RunConfig} */
|
|
152
155
|
const config = {
|
|
@@ -163,9 +166,17 @@ export function createService(opts) {
|
|
|
163
166
|
thinkingLevel: meta.defaultThinking ?? null,
|
|
164
167
|
tools: null,
|
|
165
168
|
};
|
|
169
|
+
// Final-review F2 residual (fix round 2): mark_queued must LAND before the
|
|
170
|
+
// runner can boot. A detached runner that boots faster than this round-trip
|
|
171
|
+
// used to send run_started while the row was still manual-completed → the
|
|
172
|
+
// command hit the manual fence → row stuck queued/alive with no status file
|
|
173
|
+
// → beats and run_finalized reject stale_run forever → permanent zombie.
|
|
174
|
+
// Spawn regardless of the result: with the coordinator unreachable the
|
|
175
|
+
// runner's own commands fail the same way either way — launch availability
|
|
176
|
+
// beats strict ordering.
|
|
177
|
+
await markQueued(meta.id, runId);
|
|
166
178
|
const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
|
|
167
179
|
appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
|
|
168
|
-
markQueued(meta.id, runId);
|
|
169
180
|
return { runId, pid };
|
|
170
181
|
}
|
|
171
182
|
|
|
@@ -176,9 +187,9 @@ export function createService(opts) {
|
|
|
176
187
|
* can never stack a second host on top of a live claim.
|
|
177
188
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
178
189
|
* @param {string|null} initialPrompt
|
|
179
|
-
* @returns {{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
190
|
+
* @returns {Promise<{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }>}
|
|
180
191
|
*/
|
|
181
|
-
function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
192
|
+
async function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
182
193
|
let lease;
|
|
183
194
|
try {
|
|
184
195
|
lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
@@ -190,7 +201,12 @@ export function createService(opts) {
|
|
|
190
201
|
return pendingLaunchResult(existing);
|
|
191
202
|
}
|
|
192
203
|
try {
|
|
193
|
-
|
|
204
|
+
// Same ordering contract as launchForView (final-review F2 residual):
|
|
205
|
+
// the row must leave completed/fenced before the host can start work.
|
|
206
|
+
// `return await` (not `return`) keeps the host-start lease held until
|
|
207
|
+
// the two-phase claim transaction settles.
|
|
208
|
+
if (launchOpts.markQueued !== false) await markQueued(meta.id, null);
|
|
209
|
+
return await startHostUnderLease(meta, initialPrompt, launchOpts);
|
|
194
210
|
} finally {
|
|
195
211
|
try { lease.release(); } catch { /* best effort */ }
|
|
196
212
|
}
|
|
@@ -304,7 +320,6 @@ export function createService(opts) {
|
|
|
304
320
|
}
|
|
305
321
|
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
306
322
|
appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, instanceId, hasInitialPrompt: Boolean(initialPrompt) } });
|
|
307
|
-
if (launchOpts.markQueued !== false) markQueued(meta.id, null);
|
|
308
323
|
return { ok: true, status: "started", pid, socketPath, instanceId };
|
|
309
324
|
}
|
|
310
325
|
|
|
@@ -337,17 +352,36 @@ export function createService(opts) {
|
|
|
337
352
|
/**
|
|
338
353
|
* Apply immediate heuristic auto-state and, when configured, queue a detached
|
|
339
354
|
* model pass to refine the row without blocking the live Pi child.
|
|
355
|
+
*
|
|
356
|
+
* Issue #91 (A8, service source): the classification lands through the View
|
|
357
|
+
* State Coordinator — the single writer of state.json/status.json. On apply,
|
|
358
|
+
* projection-relevant fields are refreshed from the materialized state so the
|
|
359
|
+
* caller's foreground projection (writeForegroundState) cannot clobber the
|
|
360
|
+
* patch with stale in-memory values. Ambiguous transport outcomes (timeout /
|
|
361
|
+
* connection_reset) never fall back to a direct write — the command may
|
|
362
|
+
* already be journaled, and the coordinator's boot replay is the recovery
|
|
363
|
+
* path. coordinator_disabled keeps the pre-coordinator in-memory apply.
|
|
340
364
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
341
|
-
* @param {import("../core/types.mjs").RunStatus} status
|
|
365
|
+
* @param {import("../core/types.mjs").RunStatus} status refreshed in place on apply
|
|
342
366
|
* @param {import("../core/types.mjs").EvidenceSnapshot} evidence
|
|
343
|
-
* @returns {boolean}
|
|
367
|
+
* @returns {Promise<boolean>} whether the classification was applied
|
|
344
368
|
*/
|
|
345
|
-
function queueAutoState(meta, status, evidence) {
|
|
369
|
+
async function queueAutoState(meta, status, evidence) {
|
|
346
370
|
if (!autoStateEnabled()) return false;
|
|
347
371
|
if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
348
372
|
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
349
373
|
if (!latest.trim()) return false;
|
|
350
|
-
const
|
|
374
|
+
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
375
|
+
const commandRunId = status.runId === "foreground" ? null : status.runId;
|
|
376
|
+
const result = await sendStateCommandImpl(root, {
|
|
377
|
+
type: "state_command",
|
|
378
|
+
viewId: meta.id,
|
|
379
|
+
runId: commandRunId,
|
|
380
|
+
source: "service",
|
|
381
|
+
kind: "auto_state_classified",
|
|
382
|
+
expectedRevision: null,
|
|
383
|
+
payload: { classification },
|
|
384
|
+
});
|
|
351
385
|
if (opts.autoStateRunnerScript) {
|
|
352
386
|
try {
|
|
353
387
|
launchAutoStateImpl(root, {
|
|
@@ -362,11 +396,47 @@ export function createService(opts) {
|
|
|
362
396
|
appendDiagnostic(root, meta.id, { source: "service", level: "warn", code: "auto_state_launch_failed", message: "Auto-state classifier could not be launched", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
363
397
|
}
|
|
364
398
|
}
|
|
365
|
-
|
|
399
|
+
if (result.status === "applied") {
|
|
400
|
+
const fresh = readState(root, meta.id);
|
|
401
|
+
if (fresh) {
|
|
402
|
+
status.semanticState = fresh.semanticState;
|
|
403
|
+
status.summary = fresh.summary;
|
|
404
|
+
status.question = fresh.question;
|
|
405
|
+
status.autoState = fresh.autoState ?? null;
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
if (result.reason === "coordinator_disabled") {
|
|
410
|
+
return applyAutoStateToStatus(status, classification, Date.now());
|
|
411
|
+
}
|
|
412
|
+
if (result.reason === "manual_fence" || result.reason === "no_change" || result.reason === "stale_run") {
|
|
413
|
+
// Designed fences — informational, not errors.
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, level: "warn", code: "auto_state_command_ambiguous", message: `Auto-state classification outcome unknown (${result.reason}); if the command was journaled, coordinator replay will recover it; otherwise the next classification pass will converge the row`, details: { reason: result.reason } });
|
|
417
|
+
return false;
|
|
366
418
|
}
|
|
367
419
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Fire a state command for a non-run lifecycle/metadata mutation and surface
|
|
422
|
+
* non-applied outcomes. Ambiguity contract (issue #91): timeout /
|
|
423
|
+
* connection_reset mean the command MAY already be journaled — never fall
|
|
424
|
+
* back to a direct write on those; only the explicit
|
|
425
|
+
* AGENT_BOARD_COORDINATOR=off escape hatch takes the legacy branch (the
|
|
426
|
+
* caller checks `reason === "coordinator_disabled"`).
|
|
427
|
+
* @param {object} command
|
|
428
|
+
* @param {string} [viewId] defaults to command.viewId
|
|
429
|
+
*/
|
|
430
|
+
async function sendLifecycleCommand(command, viewId = command.viewId) {
|
|
431
|
+
const result = await sendStateCommandImpl(root, command);
|
|
432
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
433
|
+
appendDiagnostic(root, viewId, { source: "service", level: "warn", code: "state_command_not_applied", message: `${command.kind} ${result.status} (${result.reason})`, details: { kind: command.kind, reason: result.reason } });
|
|
434
|
+
}
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Legacy direct write for mark_queued — coordinator_disabled escape hatch only. */
|
|
439
|
+
function markQueuedDirect(viewId, runId) {
|
|
370
440
|
const state = readState(root, viewId) ?? blankState(viewId);
|
|
371
441
|
state.currentRunId = runId;
|
|
372
442
|
state.semanticState = "queued";
|
|
@@ -383,23 +453,111 @@ export function createService(opts) {
|
|
|
383
453
|
writeState(root, state);
|
|
384
454
|
}
|
|
385
455
|
|
|
386
|
-
/** @param {string} viewId @returns {{
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
456
|
+
/** @param {string} viewId @param {string|null} runId @returns {Promise<{status: string, reason: string|null, materializedRevision?: number}>} */
|
|
457
|
+
function markQueued(viewId, runId) {
|
|
458
|
+
if (coordinatorDisabled()) {
|
|
459
|
+
markQueuedDirect(viewId, runId);
|
|
460
|
+
return Promise.resolve({ status: "applied", reason: "coordinator_disabled" });
|
|
461
|
+
}
|
|
462
|
+
// Fire-and-forget: legacy issued this write right after launch() without
|
|
463
|
+
// waiting on the runner, and the coordinator serializes arrival order the
|
|
464
|
+
// same way. N1 (Task 1): command.runId stays null — the new run's id rides
|
|
465
|
+
// in the payload and the decision branch pins currentRunId, so the generic
|
|
466
|
+
// stale-run guard cannot fire against the PREVIOUS run.
|
|
467
|
+
//
|
|
468
|
+
// source is "dashboard-user" on purpose (final-review F1): launching a run
|
|
469
|
+
// from these flows is ALWAYS user intent (reply / dispatch / attach), and
|
|
470
|
+
// the manual fence exists to protect the user's verdict from AUTOMATED late
|
|
471
|
+
// writes — a user-initiated re-launch IS the user changing the verdict, so
|
|
472
|
+
// mark_queued must lift the fence for the rest of the new run (otherwise a
|
|
473
|
+
// reply on a done row would execute invisibly while the row stays
|
|
474
|
+
// completed). Automated re-launch stays guarded upstream: the job-runner's
|
|
475
|
+
// post-exit follow-up claim refuses manually-completed rows
|
|
476
|
+
// (drainQueuedFollowUp: `if (isManualCompletion(readState(...))) return;`),
|
|
477
|
+
// and the service-side drain path delivers already-queued user follow-ups
|
|
478
|
+
// with no fence by design (prompt-not-lost, issue #70 — its live-host input
|
|
479
|
+
// path never fenced either).
|
|
480
|
+
// Fix round 2 (launch ordering): this promise is now AWAITED by launchForView
|
|
481
|
+
// / launchHost before they spawn — see the ordering notes there.
|
|
482
|
+
return sendLifecycleCommand({
|
|
483
|
+
type: "state_command",
|
|
484
|
+
viewId,
|
|
485
|
+
runId: null,
|
|
486
|
+
source: "dashboard-user",
|
|
487
|
+
kind: "mark_queued",
|
|
488
|
+
expectedRevision: null,
|
|
489
|
+
payload: { runId },
|
|
490
|
+
}).catch(() => ({ status: "rejected", reason: "connection_reset" }));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Legacy direct write for markVisited — coordinator_disabled escape hatch only. */
|
|
494
|
+
function markVisitedDirect(row) {
|
|
495
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
391
496
|
state.lastVisitedAt = Date.now();
|
|
392
497
|
state.updatedAt = Date.now();
|
|
393
498
|
writeState(root, state);
|
|
394
|
-
return { ok: true };
|
|
395
499
|
}
|
|
396
500
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
501
|
+
/**
|
|
502
|
+
* @param {string} viewId
|
|
503
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
504
|
+
*/
|
|
505
|
+
async function markVisited(viewId) {
|
|
399
506
|
const row = loadRow(root, viewId);
|
|
400
507
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
401
|
-
if (
|
|
402
|
-
|
|
508
|
+
if (coordinatorDisabled()) {
|
|
509
|
+
markVisitedDirect(row);
|
|
510
|
+
return { ok: true };
|
|
511
|
+
}
|
|
512
|
+
// dashboard-user (not "service"): visiting is a user action, and the
|
|
513
|
+
// manual fence must not stop visit-recency tracking on completed rows —
|
|
514
|
+
// legacy stamped lastVisitedAt unconditionally. Whitelist: lastVisitedAt only.
|
|
515
|
+
const result = await sendLifecycleCommand({
|
|
516
|
+
type: "state_command",
|
|
517
|
+
viewId,
|
|
518
|
+
source: "dashboard-user",
|
|
519
|
+
kind: "patch_fields",
|
|
520
|
+
expectedRevision: null,
|
|
521
|
+
payload: { state: { lastVisitedAt: Date.now() } },
|
|
522
|
+
}, viewId);
|
|
523
|
+
if (result.reason === "coordinator_disabled") markVisitedDirect(row);
|
|
524
|
+
// Visit tracking is best-effort metadata — never fail the UI action.
|
|
525
|
+
return { ok: true };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Legacy direct write for adoptSession — coordinator_disabled escape hatch only. */
|
|
529
|
+
function adoptStateDirect(viewId, state) {
|
|
530
|
+
const target = state ?? readState(root, viewId) ?? blankState(viewId);
|
|
531
|
+
target.semanticState = "idle";
|
|
532
|
+
target.processState = "exited";
|
|
533
|
+
target.needsInput = false;
|
|
534
|
+
target.hasError = false;
|
|
535
|
+
target.question = null;
|
|
536
|
+
target.pendingQuestions = [];
|
|
537
|
+
target.error = null;
|
|
538
|
+
target.summary = "Backgrounded session";
|
|
539
|
+
target.updatedAt = Date.now();
|
|
540
|
+
target.lastActivityAt = Date.now();
|
|
541
|
+
writeState(root, target);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Route the adopt-idle transition through the coordinator (fenced, journaled). */
|
|
545
|
+
async function adoptIdleState(viewId, state) {
|
|
546
|
+
const result = await sendLifecycleCommand({
|
|
547
|
+
type: "state_command",
|
|
548
|
+
viewId,
|
|
549
|
+
runId: null,
|
|
550
|
+
source: "dashboard-user",
|
|
551
|
+
kind: "adopt_session",
|
|
552
|
+
expectedRevision: null,
|
|
553
|
+
payload: {},
|
|
554
|
+
}, viewId);
|
|
555
|
+
if (result.reason === "coordinator_disabled") adoptStateDirect(viewId, state);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Legacy direct-write completion — only reachable when the coordinator is
|
|
559
|
+
* explicitly disabled (AGENT_BOARD_COORDINATOR=off tests/escape hatch). */
|
|
560
|
+
function completeViewDirect(state) {
|
|
403
561
|
state.semanticState = "completed";
|
|
404
562
|
state.processState = "exited";
|
|
405
563
|
state.needsInput = false;
|
|
@@ -414,21 +572,72 @@ export function createService(opts) {
|
|
|
414
572
|
// Also clear autoState in the run status so in-flight model passes
|
|
415
573
|
// (job-runner / state-runner) see the manual completion and skip refinement.
|
|
416
574
|
if (state.currentRunId) {
|
|
417
|
-
const status = readStatus(root, viewId, state.currentRunId);
|
|
575
|
+
const status = readStatus(root, state.viewId, state.currentRunId);
|
|
418
576
|
if (status) {
|
|
419
577
|
status.autoState = null;
|
|
420
578
|
writeStatus(root, status);
|
|
421
579
|
}
|
|
422
580
|
}
|
|
423
581
|
writeState(root, state);
|
|
424
|
-
return { ok: true };
|
|
425
582
|
}
|
|
426
583
|
|
|
427
584
|
/**
|
|
585
|
+
* Explicitly mark an inactive session as done via the View State Coordinator
|
|
586
|
+
* (issue #91): the command is journaled and materialized by the single owner,
|
|
587
|
+
* which also rejects stale-run and fenced manual-completion overwrites.
|
|
428
588
|
* @param {string} viewId
|
|
429
|
-
* @returns {{ ok: boolean, error?: string }}
|
|
589
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
430
590
|
*/
|
|
431
|
-
function
|
|
591
|
+
async function completeView(viewId) {
|
|
592
|
+
const row = loadRow(root, viewId);
|
|
593
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
594
|
+
// Fast local pre-check for instant UI feedback; the coordinator's decision
|
|
595
|
+
// stays authoritative (its "busy" rejection maps to the same wording).
|
|
596
|
+
if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
597
|
+
const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
|
|
598
|
+
const result = await sendStateCommandImpl(root, {
|
|
599
|
+
type: "state_command",
|
|
600
|
+
viewId,
|
|
601
|
+
runId: state.currentRunId ?? null,
|
|
602
|
+
source: "dashboard-user",
|
|
603
|
+
kind: "mark_completed",
|
|
604
|
+
expectedRevision: null,
|
|
605
|
+
payload: {},
|
|
606
|
+
});
|
|
607
|
+
if (result.status === "applied") return { ok: true };
|
|
608
|
+
if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
609
|
+
if (result.reason === "coordinator_disabled") {
|
|
610
|
+
completeViewDirect(state);
|
|
611
|
+
return { ok: true };
|
|
612
|
+
}
|
|
613
|
+
// Ambiguous outcomes (timeout / connection_reset: the command MAY already be
|
|
614
|
+
// journaled) and real rejections surface verbatim — never fall back to a
|
|
615
|
+
// direct write here, it would bypass the single-writer fence.
|
|
616
|
+
return { ok: false, error: result.reason ?? "state_command_failed" };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Legacy direct write for archiveView's busy-row stop — coordinator_disabled escape hatch only. */
|
|
620
|
+
function archiveStateDirect(row) {
|
|
621
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
622
|
+
state.semanticState = "stopped";
|
|
623
|
+
state.processState = "exited";
|
|
624
|
+
state.needsInput = false;
|
|
625
|
+
state.hasError = false;
|
|
626
|
+
state.question = null;
|
|
627
|
+
state.pendingQuestions = [];
|
|
628
|
+
state.error = null;
|
|
629
|
+
state.autoState = null;
|
|
630
|
+
state.summary = "Stopped";
|
|
631
|
+
state.lastActivityAt = Date.now();
|
|
632
|
+
state.updatedAt = Date.now();
|
|
633
|
+
writeState(root, state);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* @param {string} viewId
|
|
638
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
639
|
+
*/
|
|
640
|
+
async function archiveView(viewId) {
|
|
432
641
|
const row = loadRow(root, viewId);
|
|
433
642
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
434
643
|
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
@@ -438,19 +647,17 @@ export function createService(opts) {
|
|
|
438
647
|
if (pid) killProcess(pid);
|
|
439
648
|
}
|
|
440
649
|
if (isAgentBusy(row)) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
state.updatedAt = Date.now();
|
|
453
|
-
writeState(root, state);
|
|
650
|
+
// dashboard-user: archiving is a user action. Busy rows are allowed by
|
|
651
|
+
// the decision kind (archiving a working row stops it — legacy parity).
|
|
652
|
+
const result = await sendLifecycleCommand({
|
|
653
|
+
type: "state_command",
|
|
654
|
+
viewId,
|
|
655
|
+
source: "dashboard-user",
|
|
656
|
+
kind: "archive_view",
|
|
657
|
+
expectedRevision: null,
|
|
658
|
+
payload: {},
|
|
659
|
+
}, viewId);
|
|
660
|
+
if (result.reason === "coordinator_disabled") archiveStateDirect(row);
|
|
454
661
|
}
|
|
455
662
|
row.meta.archived = true;
|
|
456
663
|
writeMeta(root, row.meta);
|
|
@@ -525,14 +732,30 @@ export function createService(opts) {
|
|
|
525
732
|
/**
|
|
526
733
|
* @param {import("../core/store.mjs").Row} row
|
|
527
734
|
* @param {import("../core/types.mjs").RunStatus} status
|
|
735
|
+
* @returns {Promise<void>}
|
|
528
736
|
*/
|
|
529
|
-
function writeForegroundState(row, status) {
|
|
737
|
+
async function writeForegroundState(row, status) {
|
|
530
738
|
const projected = projectViewState(status, Date.now(), readState(root, row.meta.id) ?? row.state ?? null);
|
|
531
739
|
// Foreground turns are driven by the interactive Pi process, not a detached
|
|
532
740
|
// runner, so keep currentRunId null. This prevents reconcile()/stop() from
|
|
533
|
-
// treating a foreground turn as a managed background runner pid.
|
|
741
|
+
// treating a foreground turn as a managed background runner pid. (The
|
|
742
|
+
// decision kind force-nulls it too; the caller-side null keeps the
|
|
743
|
+
// coordinator_disabled direct write at legacy parity.)
|
|
534
744
|
projected.currentRunId = null;
|
|
535
|
-
|
|
745
|
+
if (coordinatorDisabled()) {
|
|
746
|
+
writeState(root, projected);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const result = await sendLifecycleCommand({
|
|
750
|
+
type: "state_command",
|
|
751
|
+
viewId: row.meta.id,
|
|
752
|
+
runId: null,
|
|
753
|
+
source: "service",
|
|
754
|
+
kind: "sync_foreground",
|
|
755
|
+
expectedRevision: null,
|
|
756
|
+
payload: { projection: projected },
|
|
757
|
+
}, row.meta.id);
|
|
758
|
+
if (result.reason === "coordinator_disabled") writeState(root, projected);
|
|
536
759
|
}
|
|
537
760
|
|
|
538
761
|
/** @param {string} sessionFile */
|
|
@@ -1135,7 +1358,7 @@ export function createService(opts) {
|
|
|
1135
1358
|
let hostMode = "pty";
|
|
1136
1359
|
if (!pty.ok) hostMode = "json-runner";
|
|
1137
1360
|
if (pty.ok) {
|
|
1138
|
-
const launched = launchHost(row.meta, prompt);
|
|
1361
|
+
const launched = await launchHost(row.meta, prompt);
|
|
1139
1362
|
if (launched.ok && launched.status !== "started") {
|
|
1140
1363
|
// An existing claim owns the view; the item stays queued and retries
|
|
1141
1364
|
// after that host becomes ready (issue #70 prompt-not-lost invariant).
|
|
@@ -1152,7 +1375,7 @@ export function createService(opts) {
|
|
|
1152
1375
|
return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
|
|
1153
1376
|
}
|
|
1154
1377
|
if (hostMode === "json-runner") {
|
|
1155
|
-
runId = launchForView(row.meta, prompt, runKindForFollowUp(item))
|
|
1378
|
+
({ runId } = await launchForView(row.meta, prompt, runKindForFollowUp(item)));
|
|
1156
1379
|
}
|
|
1157
1380
|
completeFollowUp(root, viewId, item.id, { runId });
|
|
1158
1381
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
|
|
@@ -1164,8 +1387,15 @@ export function createService(opts) {
|
|
|
1164
1387
|
}
|
|
1165
1388
|
}
|
|
1166
1389
|
|
|
1167
|
-
/**
|
|
1168
|
-
|
|
1390
|
+
/**
|
|
1391
|
+
* Mirror a foreground/hosted session event into the row state. Async since
|
|
1392
|
+
* issue #91: the terminal classification routes through the View State
|
|
1393
|
+
* Coordinator command socket (fire-and-forget callers are fine — the returned
|
|
1394
|
+
* promise resolves after the classification outcome is known).
|
|
1395
|
+
* @param {import("../core/store.mjs").Row} row @param {any} event
|
|
1396
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
1397
|
+
*/
|
|
1398
|
+
async function syncRowEvent(row, event) {
|
|
1169
1399
|
const now = Date.now();
|
|
1170
1400
|
const status = statusFromRow(row);
|
|
1171
1401
|
let evidence = readEvidence(root, row.meta.id);
|
|
@@ -1187,26 +1417,55 @@ export function createService(opts) {
|
|
|
1187
1417
|
status.error = null;
|
|
1188
1418
|
status.summary = "Running…";
|
|
1189
1419
|
status.lastActivityAt = now;
|
|
1190
|
-
|
|
1420
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1421
|
+
void writeForegroundState(row, status);
|
|
1191
1422
|
return true;
|
|
1192
1423
|
}
|
|
1193
1424
|
|
|
1194
1425
|
if (event.type === "agent_end") {
|
|
1426
|
+
// #46-class fence (issue #91): a manual completion is a user verdict that
|
|
1427
|
+
// outlives the turn. A late/duplicate agent_end after markCompleted must
|
|
1428
|
+
// not resurrect the row via finalizeRun + projection — skip all semantic
|
|
1429
|
+
// writes (baseline, steering, classification) and keep evidence only.
|
|
1430
|
+
// The fence read is repeated after the classification await below; the
|
|
1431
|
+
// window between the two reads is covered by the coordinator's own
|
|
1432
|
+
// manual_fence rejection plus the guarded tail write.
|
|
1433
|
+
if (isManualCompletion(readState(root, row.meta.id))) {
|
|
1434
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1435
|
+
writeEvidence(root, evidence);
|
|
1436
|
+
return false;
|
|
1437
|
+
}
|
|
1195
1438
|
finalizeRun(status, { exitCode: 0 }, now);
|
|
1196
1439
|
finalizeEvidence(evidence, status, now);
|
|
1197
1440
|
const steering = readSteering(root, row.meta.id);
|
|
1441
|
+
let classificationQueued = false;
|
|
1198
1442
|
if (steering.status === "plan_requested" || steering.status === "changes_requested") {
|
|
1199
1443
|
recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
|
|
1200
1444
|
status.semanticState = "needs_input";
|
|
1201
1445
|
status.question = "Approve this plan?";
|
|
1202
1446
|
status.summary = "Plan ready for approval";
|
|
1203
|
-
} else
|
|
1204
|
-
|
|
1447
|
+
} else {
|
|
1448
|
+
// Baseline the exited projection BEFORE the classification command:
|
|
1449
|
+
// the coordinator's delegated auto-state rules key on processState,
|
|
1450
|
+
// and its materialized patch lands after this write and wins. When
|
|
1451
|
+
// the coordinator applies, the projection write below is skipped so
|
|
1452
|
+
// the stale in-memory fields cannot clobber the patch (#46 class).
|
|
1453
|
+
// The legacy coordinator_disabled path applies in-memory instead and
|
|
1454
|
+
// still needs the tail projection write (pre-coordinator behavior).
|
|
1455
|
+
// Awaited: this ordering IS the fence — the classification command
|
|
1456
|
+
// must observe processState "exited" or its delegated rules skip.
|
|
1457
|
+
await writeForegroundState(row, status);
|
|
1458
|
+
classificationQueued = await queueAutoState(row.meta, status, evidence);
|
|
1459
|
+
if (classificationQueued) finalizeEvidence(evidence, status, now);
|
|
1205
1460
|
}
|
|
1206
1461
|
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1207
1462
|
writeEvidence(root, evidence);
|
|
1208
1463
|
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
1209
|
-
|
|
1464
|
+
// Re-read the fence after the classification await: a completion landing
|
|
1465
|
+
// between the baseline write and the coordinator's decision read gets
|
|
1466
|
+
// manual_fence back (classificationQueued=false), and writing the stale
|
|
1467
|
+
// in-memory projection here would clobber it (#46 class).
|
|
1468
|
+
if (!isManualCompletion(readState(root, row.meta.id)) && !(classificationQueued && !coordinatorDisabled())) await writeForegroundState(row, status);
|
|
1210
1469
|
pruneWarmHosts({ keepViewId: row.meta.id });
|
|
1211
1470
|
// Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
|
|
1212
1471
|
// queue item's own state records the outcome, ordering is preserved by
|
|
@@ -1217,7 +1476,8 @@ export function createService(opts) {
|
|
|
1217
1476
|
|
|
1218
1477
|
if (reduceEvent(status, event, now, { interactive: true })) {
|
|
1219
1478
|
status.processState = "alive";
|
|
1220
|
-
|
|
1479
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1480
|
+
void writeForegroundState(row, status);
|
|
1221
1481
|
return true;
|
|
1222
1482
|
}
|
|
1223
1483
|
return false;
|
|
@@ -1237,9 +1497,9 @@ export function createService(opts) {
|
|
|
1237
1497
|
* model?: string|null,
|
|
1238
1498
|
* thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
|
|
1239
1499
|
* }} [dispatchOpts]
|
|
1240
|
-
* @returns {{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
|
|
1500
|
+
* @returns {Promise<{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
|
|
1241
1501
|
*/
|
|
1242
|
-
dispatch(text, dispatchOpts = {}) {
|
|
1502
|
+
async dispatch(text, dispatchOpts = {}) {
|
|
1243
1503
|
const prompt = String(text || "").trim();
|
|
1244
1504
|
if (!prompt) return { ok: false, error: "Empty task" };
|
|
1245
1505
|
|
|
@@ -1271,7 +1531,7 @@ export function createService(opts) {
|
|
|
1271
1531
|
let fallbackReason;
|
|
1272
1532
|
let queued;
|
|
1273
1533
|
if (pty.ok) {
|
|
1274
|
-
const launched = launchHost(meta, prompt);
|
|
1534
|
+
const launched = await launchHost(meta, prompt);
|
|
1275
1535
|
if (launched.ok) {
|
|
1276
1536
|
hostMode = "pty";
|
|
1277
1537
|
if (launched.status !== "started") {
|
|
@@ -1283,11 +1543,11 @@ export function createService(opts) {
|
|
|
1283
1543
|
}
|
|
1284
1544
|
} else {
|
|
1285
1545
|
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
1286
|
-
launchForView(meta, prompt, "dispatch");
|
|
1546
|
+
await launchForView(meta, prompt, "dispatch");
|
|
1287
1547
|
}
|
|
1288
1548
|
} else {
|
|
1289
1549
|
fallbackReason = nodePtyFallbackMessage(pty);
|
|
1290
|
-
launchForView(meta, prompt, "dispatch");
|
|
1550
|
+
await launchForView(meta, prompt, "dispatch");
|
|
1291
1551
|
}
|
|
1292
1552
|
queueGeneratedTitle(meta, prompt);
|
|
1293
1553
|
return {
|
|
@@ -1356,7 +1616,7 @@ export function createService(opts) {
|
|
|
1356
1616
|
let hostMode = null;
|
|
1357
1617
|
let fallbackReason;
|
|
1358
1618
|
if (pty.ok) {
|
|
1359
|
-
const launched = launchHost(row.meta, prompt);
|
|
1619
|
+
const launched = await launchHost(row.meta, prompt);
|
|
1360
1620
|
if (launched.ok) {
|
|
1361
1621
|
if (launched.status !== "started") {
|
|
1362
1622
|
// The existing claim will consume the prompt later — keep it queued
|
|
@@ -1377,7 +1637,7 @@ export function createService(opts) {
|
|
|
1377
1637
|
}
|
|
1378
1638
|
if (!hostMode) {
|
|
1379
1639
|
if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
|
|
1380
|
-
launchForView(row.meta, prompt, runKindForKind(kind));
|
|
1640
|
+
await launchForView(row.meta, prompt, runKindForKind(kind));
|
|
1381
1641
|
hostMode = "json-runner";
|
|
1382
1642
|
}
|
|
1383
1643
|
return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
|
|
@@ -1475,7 +1735,7 @@ export function createService(opts) {
|
|
|
1475
1735
|
return { kind: "session", sessionFile: row.meta.sessionFile };
|
|
1476
1736
|
},
|
|
1477
1737
|
|
|
1478
|
-
adoptSession(adoptOpts = {}) {
|
|
1738
|
+
async adoptSession(adoptOpts = {}) {
|
|
1479
1739
|
const sessionFile = String(adoptOpts.sessionFile || "").trim();
|
|
1480
1740
|
if (!sessionFile) return { ok: false, error: "No session file to adopt" };
|
|
1481
1741
|
const existing = rowForSession(sessionFile);
|
|
@@ -1484,18 +1744,7 @@ export function createService(opts) {
|
|
|
1484
1744
|
if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
|
|
1485
1745
|
writeMeta(root, existing.meta);
|
|
1486
1746
|
if (!isAgentBusy(existing)) {
|
|
1487
|
-
|
|
1488
|
-
state.semanticState = "idle";
|
|
1489
|
-
state.processState = "exited";
|
|
1490
|
-
state.needsInput = false;
|
|
1491
|
-
state.hasError = false;
|
|
1492
|
-
state.question = null;
|
|
1493
|
-
state.pendingQuestions = [];
|
|
1494
|
-
state.error = null;
|
|
1495
|
-
state.summary = "Backgrounded session";
|
|
1496
|
-
state.updatedAt = Date.now();
|
|
1497
|
-
state.lastActivityAt = Date.now();
|
|
1498
|
-
writeState(root, state);
|
|
1747
|
+
await adoptIdleState(existing.meta.id, readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id));
|
|
1499
1748
|
}
|
|
1500
1749
|
appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
|
|
1501
1750
|
return { ok: true, viewId: existing.meta.id, reused: true };
|
|
@@ -1517,12 +1766,7 @@ export function createService(opts) {
|
|
|
1517
1766
|
sessionFile,
|
|
1518
1767
|
});
|
|
1519
1768
|
const state = readState(root, id) ?? blankState(id);
|
|
1520
|
-
|
|
1521
|
-
state.processState = "exited";
|
|
1522
|
-
state.summary = "Backgrounded session";
|
|
1523
|
-
state.updatedAt = Date.now();
|
|
1524
|
-
state.lastActivityAt = Date.now();
|
|
1525
|
-
writeState(root, state);
|
|
1769
|
+
await adoptIdleState(id, state);
|
|
1526
1770
|
appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
|
|
1527
1771
|
return { ok: true, viewId: meta.id, reused: false };
|
|
1528
1772
|
},
|
|
@@ -1569,7 +1813,7 @@ export function createService(opts) {
|
|
|
1569
1813
|
* Explicitly mark an inactive session as done. Successful runs settle as
|
|
1570
1814
|
* `idle` until the user reviews and confirms this action from the dashboard.
|
|
1571
1815
|
* @param {string} viewId
|
|
1572
|
-
* @returns {{ ok: boolean, error?: string }}
|
|
1816
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
1573
1817
|
*/
|
|
1574
1818
|
markCompleted(viewId) {
|
|
1575
1819
|
return completeView(viewId);
|
|
@@ -1640,7 +1884,12 @@ export function createService(opts) {
|
|
|
1640
1884
|
return { state, summary: summarizeSteering(state) };
|
|
1641
1885
|
},
|
|
1642
1886
|
|
|
1643
|
-
|
|
1887
|
+
/**
|
|
1888
|
+
* Bulk mark sessions done, skipping live/already-done rows.
|
|
1889
|
+
* @param {string[]} viewIds
|
|
1890
|
+
* @returns {Promise<{ ok: boolean, completed: number, skipped: number, completedIds: string[] }>}
|
|
1891
|
+
*/
|
|
1892
|
+
async markCompletedMany(viewIds) {
|
|
1644
1893
|
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
1645
1894
|
let completed = 0;
|
|
1646
1895
|
let skipped = 0;
|
|
@@ -1651,7 +1900,7 @@ export function createService(opts) {
|
|
|
1651
1900
|
skipped += 1;
|
|
1652
1901
|
continue;
|
|
1653
1902
|
}
|
|
1654
|
-
const res = completeView(viewId);
|
|
1903
|
+
const res = await completeView(viewId);
|
|
1655
1904
|
if (res.ok) {
|
|
1656
1905
|
completed += 1;
|
|
1657
1906
|
completedIds.push(viewId);
|
|
@@ -1672,9 +1921,9 @@ export function createService(opts) {
|
|
|
1672
1921
|
/**
|
|
1673
1922
|
* Bulk archive explicit row ids, skipping live/missing rows.
|
|
1674
1923
|
* @param {string[]} viewIds
|
|
1675
|
-
* @returns {{ ok: boolean, archived: number, skipped: number }}
|
|
1924
|
+
* @returns {Promise<{ ok: boolean, archived: number, skipped: number }>}
|
|
1676
1925
|
*/
|
|
1677
|
-
archiveMany(viewIds) {
|
|
1926
|
+
async archiveMany(viewIds) {
|
|
1678
1927
|
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
1679
1928
|
let archived = 0;
|
|
1680
1929
|
let skipped = 0;
|
|
@@ -1684,7 +1933,7 @@ export function createService(opts) {
|
|
|
1684
1933
|
skipped += 1;
|
|
1685
1934
|
continue;
|
|
1686
1935
|
}
|
|
1687
|
-
const res = archiveView(viewId);
|
|
1936
|
+
const res = await archiveView(viewId);
|
|
1688
1937
|
if (res.ok) archived += 1;
|
|
1689
1938
|
else skipped += 1;
|
|
1690
1939
|
}
|
|
@@ -1719,9 +1968,9 @@ export function createService(opts) {
|
|
|
1719
1968
|
* Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
|
|
1720
1969
|
* or the runner was killed). If a terminal status exists, project it; otherwise mark
|
|
1721
1970
|
* the row failed/stale. Safe to call on every dashboard open and on session_start.
|
|
1722
|
-
* @returns {number} number of rows reconciled.
|
|
1971
|
+
* @returns {Promise<number>} number of rows reconciled.
|
|
1723
1972
|
*/
|
|
1724
|
-
reconcile() {
|
|
1973
|
+
async reconcile() {
|
|
1725
1974
|
const now = Date.now();
|
|
1726
1975
|
let fixed = 0;
|
|
1727
1976
|
for (const row of listRows(root)) {
|
|
@@ -1734,16 +1983,36 @@ export function createService(opts) {
|
|
|
1734
1983
|
// its runner pid may legitimately be absent — never finalize it to failed yet.
|
|
1735
1984
|
if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < HOST_START_GRACE_MS) continue;
|
|
1736
1985
|
const failed = row.host.state === "starting" || row.host.state === "alive" || row.host.state === "failed" || Boolean(row.host.error) || (row.host.exitCode !== null && row.host.exitCode !== 0);
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1986
|
+
// F6: failed verdicts must carry a reason (legacy guaranteed an
|
|
1987
|
+
// error string). The row has no currentRunId here, so only the
|
|
1988
|
+
// state half participates (no status.json to sync).
|
|
1989
|
+
const result = await sendLifecycleCommand({
|
|
1990
|
+
type: "state_command",
|
|
1991
|
+
viewId: row.meta.id,
|
|
1992
|
+
runId: null,
|
|
1993
|
+
source: "service",
|
|
1994
|
+
kind: "reconcile_finalize",
|
|
1995
|
+
expectedRevision: null,
|
|
1996
|
+
payload: {
|
|
1997
|
+
semanticState: failed ? "failed" : "idle",
|
|
1998
|
+
summary: failed ? "Failed (PTY host exited)" : "Needs instructions",
|
|
1999
|
+
reason: failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null,
|
|
2000
|
+
},
|
|
2001
|
+
}, row.meta.id);
|
|
2002
|
+
if (result.status !== "applied") {
|
|
2003
|
+
if (result.reason === "coordinator_disabled") {
|
|
2004
|
+
s.semanticState = failed ? "failed" : "idle";
|
|
2005
|
+
s.processState = "exited";
|
|
2006
|
+
s.hasError = failed;
|
|
2007
|
+
s.needsInput = false;
|
|
2008
|
+
s.question = null;
|
|
2009
|
+
s.pendingQuestions = [];
|
|
2010
|
+
s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
|
|
2011
|
+
s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
|
|
2012
|
+
s.updatedAt = now;
|
|
2013
|
+
writeState(root, s);
|
|
2014
|
+
} else continue; // rejected/ambiguous: nothing mutated, not fixed
|
|
2015
|
+
}
|
|
1747
2016
|
appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
|
|
1748
2017
|
fixed += 1;
|
|
1749
2018
|
}
|
|
@@ -1752,16 +2021,52 @@ export function createService(opts) {
|
|
|
1752
2021
|
if (row.alive) continue;
|
|
1753
2022
|
const status = readStatus(root, row.meta.id, s.currentRunId);
|
|
1754
2023
|
if (status?.endedAt) {
|
|
1755
|
-
|
|
2024
|
+
// The run's terminal status exists but the row was never materialized
|
|
2025
|
+
// from it (crash between the two writes, or a pre-coordinator row).
|
|
2026
|
+
// Project mode lets the status's own verdict govern (completed rows
|
|
2027
|
+
// must not be forced to failed/idle).
|
|
2028
|
+
const result = await sendLifecycleCommand({
|
|
2029
|
+
type: "state_command",
|
|
2030
|
+
viewId: row.meta.id,
|
|
2031
|
+
runId: s.currentRunId,
|
|
2032
|
+
source: "service",
|
|
2033
|
+
kind: "reconcile_finalize",
|
|
2034
|
+
expectedRevision: null,
|
|
2035
|
+
payload: { project: true },
|
|
2036
|
+
}, row.meta.id);
|
|
2037
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
2038
|
+
// Fix round 2 (F5 leftover): a rejected/ambiguous project-mode
|
|
2039
|
+
// row (manual fence, no_change, stale_run, timeout) mutated
|
|
2040
|
+
// nothing — it must not count toward the fixed tally.
|
|
2041
|
+
continue;
|
|
2042
|
+
}
|
|
2043
|
+
if (result.reason === "coordinator_disabled") writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
|
|
1756
2044
|
} else {
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
2045
|
+
const result = await sendLifecycleCommand({
|
|
2046
|
+
type: "state_command",
|
|
2047
|
+
viewId: row.meta.id,
|
|
2048
|
+
runId: s.currentRunId,
|
|
2049
|
+
source: "service",
|
|
2050
|
+
kind: "reconcile_finalize",
|
|
2051
|
+
expectedRevision: null,
|
|
2052
|
+
payload: {
|
|
2053
|
+
semanticState: "failed",
|
|
2054
|
+
summary: "Failed (runner exited)",
|
|
2055
|
+
reason: s.error ?? "Runner exited unexpectedly",
|
|
2056
|
+
},
|
|
2057
|
+
}, row.meta.id);
|
|
2058
|
+
if (result.status !== "applied") {
|
|
2059
|
+
if (result.reason === "coordinator_disabled") {
|
|
2060
|
+
s.semanticState = "failed";
|
|
2061
|
+
s.processState = "exited";
|
|
2062
|
+
s.hasError = true;
|
|
2063
|
+
s.needsInput = false;
|
|
2064
|
+
s.error = s.error ?? "Runner exited unexpectedly";
|
|
2065
|
+
s.summary = "Failed (runner exited)";
|
|
2066
|
+
s.updatedAt = now;
|
|
2067
|
+
writeState(root, s);
|
|
2068
|
+
} else continue;
|
|
2069
|
+
}
|
|
1765
2070
|
}
|
|
1766
2071
|
fixed += 1;
|
|
1767
2072
|
}
|
|
@@ -1782,20 +2087,20 @@ export function createService(opts) {
|
|
|
1782
2087
|
* looking stale after the user types a follow-up in the real Pi session.
|
|
1783
2088
|
* @param {string|undefined} sessionFile
|
|
1784
2089
|
* @param {any} event
|
|
1785
|
-
* @returns {boolean} whether a managed row was updated
|
|
2090
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
1786
2091
|
*/
|
|
1787
2092
|
syncForegroundEvent(sessionFile, event) {
|
|
1788
|
-
if (!sessionFile || !event?.type) return false;
|
|
2093
|
+
if (!sessionFile || !event?.type) return Promise.resolve(false);
|
|
1789
2094
|
const row = rowForSession(sessionFile);
|
|
1790
|
-
if (!row) return false;
|
|
2095
|
+
if (!row) return Promise.resolve(false);
|
|
1791
2096
|
return syncRowEvent(row, event);
|
|
1792
2097
|
},
|
|
1793
2098
|
|
|
1794
2099
|
/** @param {string|undefined} viewId @param {any} event */
|
|
1795
2100
|
syncHostedEvent(viewId, event) {
|
|
1796
|
-
if (!viewId || !event?.type) return false;
|
|
2101
|
+
if (!viewId || !event?.type) return Promise.resolve(false);
|
|
1797
2102
|
const row = loadRow(root, viewId);
|
|
1798
|
-
if (!row) return false;
|
|
2103
|
+
if (!row) return Promise.resolve(false);
|
|
1799
2104
|
return syncRowEvent(row, event);
|
|
1800
2105
|
},
|
|
1801
2106
|
|