pi-crew 0.10.2 → 0.10.4
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/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* degrade.ts — MuxSurface A1 pane-dead → headless hạ cấp (spec §7, D3).
|
|
3
|
+
*
|
|
4
|
+
* Bốn lớp, từ thuần sang có trạng thái:
|
|
5
|
+
*
|
|
6
|
+
* 1. PURE — `classifyOnExit` (classify timeout 2s sau `onExit`),
|
|
7
|
+
* `nextLockoutCounts` / `nextLockoutCountsForBatch` (anti-flap cause-group),
|
|
8
|
+
* cặp đếm spawn-fail (`nextConsecutiveSpawnFails` + threshold).
|
|
9
|
+
* 2. PROBE — `makeTerminalEventProbe`: implementation mặc định của callback
|
|
10
|
+
* `waitForCompleted`, tail RUN events.jsonl tìm `worker.completed` mà worker
|
|
11
|
+
* tự ghi qua WP-9 channel (D7: report trước khi chết) — đọc incremental,
|
|
12
|
+
* clock/poll injectable để test không chờ thật.
|
|
13
|
+
* 3. REDUCER — các hàm thuần trên `ManifestSurfaceState` (+ normalize an toàn
|
|
14
|
+
* khi manifest cũ/thối), và `planHeadlessRedeplays` biến danh sách degrade
|
|
15
|
+
* thành task re-queue + resume note.
|
|
16
|
+
* 4. CONTROLLER — `createSurfaceRuntimeController`: một instance per run do
|
|
17
|
+
* team-runner tạo; child-pi layer gọi qua registry theo runId (cùng mô
|
|
18
|
+
* hình module-singleton với `getActiveBrokerIssuer`). Controller sở hữu:
|
|
19
|
+
* số pane sống (cap MAX_SURFACE_WORKERS), lockout, revoke token lúc
|
|
20
|
+
* degrade, event `surface.degraded`, và hàng đợi re-dispatch mà scheduler
|
|
21
|
+
* tick lấy đi (`takeDegraded`).
|
|
22
|
+
*
|
|
23
|
+
* Thread-safety: controller chạy trong đúng process của team-runner; các notify
|
|
24
|
+
* đến tuần tự qua event loop, nên state chỉ cần plain mutation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
|
|
29
|
+
import { type AppendTeamEvent, appendEventFireAndForget } from "../../state/event-log/event-log.ts";
|
|
30
|
+
import type { ManifestSurfaceState, TeamTaskState } from "../../state/types.ts";
|
|
31
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
32
|
+
import type { SurfaceExitReason, SurfaceHandle, SurfaceProvider } from "./surface-provider.ts";
|
|
33
|
+
|
|
34
|
+
/** Trần classify sau `onExit` (spec §7: tmux poll 2s, fs.watch <100ms). */
|
|
35
|
+
export const CLASSIFY_TIMEOUT_MS = 2000;
|
|
36
|
+
/** Nhịp poll của probe chạy trong ngân sách classify (đủ thưa, không nóng CPU). */
|
|
37
|
+
export const CLASSIFY_POLL_MS = 50;
|
|
38
|
+
/** 3 spawn-fail liên tiếp trong run → surface OFF hết run (spec §7). */
|
|
39
|
+
export const SURFACE_SPAWN_FAIL_LOCKOUT_THRESHOLD = 3;
|
|
40
|
+
|
|
41
|
+
export type SurfaceDegradeCause = "pane-closed" | "mux-dead";
|
|
42
|
+
export type LockoutCounts = { pane: number; mux: number };
|
|
43
|
+
|
|
44
|
+
// ── 1. Pure decisions ─────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify kết thúc pane: chờ tín hiệu hoàn thành tối đa `timeoutMs` (2s mặc
|
|
48
|
+
* định). Hàm KHÔNG tự đo thời gian — toàn bộ timing nằm ở `waitForCompleted`
|
|
49
|
+
* (production dùng {@link makeTerminalEventProbe}; test nhét fake). Return
|
|
50
|
+
* "completed" chỉ khi tín hiệu tới trong cửa sổ; mọi lý do khác là degraded.
|
|
51
|
+
*/
|
|
52
|
+
export async function classifyOnExit(
|
|
53
|
+
handle: SurfaceHandle,
|
|
54
|
+
waitForCompleted: (ms: number) => Promise<boolean>,
|
|
55
|
+
opts?: { timeoutMs?: number },
|
|
56
|
+
): Promise<"completed" | "degraded"> {
|
|
57
|
+
if (!handle || typeof waitForCompleted !== "function") return "degraded";
|
|
58
|
+
return (await waitForCompleted(opts?.timeoutMs ?? CLASSIFY_TIMEOUT_MS)) ? "completed" : "degraded";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Anti-flap — +1 cho ĐÚNG cause group. Batch N-pane cùng lúc xử lý ở
|
|
63
|
+
* {@link nextLockoutCountsForBatch} (1 sự kiện mux chết = 1 count dù N pane).
|
|
64
|
+
*/
|
|
65
|
+
export function nextLockoutCounts(prev: LockoutCounts, cause: SurfaceDegradeCause): LockoutCounts {
|
|
66
|
+
const base = { pane: Math.max(0, prev?.pane ?? 0), mux: Math.max(0, prev?.mux ?? 0) };
|
|
67
|
+
return cause === "mux-dead" ? { ...base, mux: base.mux + 1 } : { ...base, pane: base.pane + 1 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cause-group batching (spec §7 bước 3): MỌI entry `mux-dead` trong cùng một
|
|
72
|
+
* drain window kể là MỘT sự kiện chết-toàn-cục → +1 mux duy nhất, dù N pane
|
|
73
|
+
* degrade song song; mỗi `pane-closed` vẫn cộng riêng từng pane. `surface.degraded`
|
|
74
|
+
* vẫn được ghi đủ N entry cùng cause để debug — batch chỉ áp cho LOCKOUT count.
|
|
75
|
+
*/
|
|
76
|
+
export function nextLockoutCountsForBatch(prev: LockoutCounts, causes: readonly SurfaceDegradeCause[]): LockoutCounts {
|
|
77
|
+
let counts = { pane: Math.max(0, prev?.pane ?? 0), mux: Math.max(0, prev?.mux ?? 0) };
|
|
78
|
+
if (causes.some((cause) => cause === "mux-dead")) counts = nextLockoutCounts(counts, "mux-dead");
|
|
79
|
+
for (const cause of causes) {
|
|
80
|
+
if (cause !== "pane-closed") continue;
|
|
81
|
+
counts = nextLockoutCounts(counts, "pane-closed");
|
|
82
|
+
}
|
|
83
|
+
return counts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Đếm spawn-fail liên tiếp; thành công (surface boot được) reset về 0. */
|
|
87
|
+
export function nextConsecutiveSpawnFails(prev: number, failed: boolean): number {
|
|
88
|
+
return failed ? Math.max(0, prev ?? 0) + 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Ngưỡng OFF-hết-run cho spawn-fail (spec §7: ≥3 liên tiếp). */
|
|
92
|
+
export function isSpawnFailLockout(consecutiveFails: number): boolean {
|
|
93
|
+
return consecutiveFails >= SURFACE_SPAWN_FAIL_LOCKOUT_THRESHOLD;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── 2. Terminal-event probe ───────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface TerminalEventProbeDeps {
|
|
99
|
+
/** RUN events.jsonl (`manifest.eventsPath`) — nơi worker ghi `worker.completed`. */
|
|
100
|
+
eventsPath: string;
|
|
101
|
+
/** Thu hẹp kết quả cho đúng task/run (mặc định: khớp taskId nếu biết). */
|
|
102
|
+
taskId?: string;
|
|
103
|
+
runId?: string;
|
|
104
|
+
/** Reader override cho test (default: fs.readFileSync trả undefined khi lỗi). */
|
|
105
|
+
readFile?: (path: string) => string | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Lớp IO của đường incremental (fix round 1/F3): fd mở MỘT lần cho cả cửa
|
|
108
|
+
* sổ probe, mỗi poll chỉ đọc byte mới từ offset — run-log lớn không bị
|
|
109
|
+
* full-read ~40 lần/degrade nữa (pattern hotspot perf-round cũ). Inject để
|
|
110
|
+
* test đếm byte/call mà vẫn đi đúng path production.
|
|
111
|
+
*/
|
|
112
|
+
io?: {
|
|
113
|
+
open(path: string): number;
|
|
114
|
+
size(fd: number): number;
|
|
115
|
+
/**
|
|
116
|
+
* Đọc byte [start, end). Trả CẢ `bytesRead` (fix round 2): poll có thể
|
|
117
|
+
* cắt giữa multi-byte UTF-8 char — toString thay byte lead bằng U+FFFD
|
|
118
|
+
* nên Buffer.byteLength(text) > bytesRead; offset phải tiến theo bytesRead
|
|
119
|
+
* (parity EventLogTailSource.readFromOffset) nếu không byte thật bị bỏ.
|
|
120
|
+
*/
|
|
121
|
+
read(fd: number, start: number, end: number): { text: string; bytesRead: number };
|
|
122
|
+
close(fd: number): void;
|
|
123
|
+
};
|
|
124
|
+
/** Sleep override cho test — nhận ms, resolve khi đồng hồ test cho phép. */
|
|
125
|
+
sleep?: (ms: number) => Promise<void>;
|
|
126
|
+
/** Clock (ms) override cho test (default Date.now). */
|
|
127
|
+
now?: () => number;
|
|
128
|
+
pollMs?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface TerminalEventProbe {
|
|
132
|
+
(ms: number): Promise<boolean>;
|
|
133
|
+
/** Payload của dòng completion vừa khớp (result/usage) — reset mỗi lần gọi. */
|
|
134
|
+
foundPayload(): Record<string, unknown> | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const EMPTY_PAYLOAD = Object.freeze({});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Tail incremental RUN events.jsonl trong ngân sách `budgetMs`, trả true ngay
|
|
141
|
+
* khi thấy `worker.completed` (khớp taskId/runId nếu biết).
|
|
142
|
+
*
|
|
143
|
+
* Hai chế độ đọc:
|
|
144
|
+
* - Production: incremental fd (mở 1 lần/ cửa sổ, `readSync` từ byte-offset —
|
|
145
|
+
* cùng discipline với EventLogTailSource.readFromOffset; shrink/truncate
|
|
146
|
+
* reset offset về 0). Việc đọc mỗi poll TOÀN BỘ run-log là pattern hotspot
|
|
147
|
+
* đã bị perf review gắn cờ từ trước — đừng quay lại.
|
|
148
|
+
* - Test/injected `readFile`: nội dung FULL mỗi lần, offset là char-index vào
|
|
149
|
+
* nội dung đó (hành vi cũ giữ nguyên cho fixture đơn giản).
|
|
150
|
+
*
|
|
151
|
+
* Hết ngân sách → false — caller quyết định degrade. KHÔNG bao giờ throw.
|
|
152
|
+
*/
|
|
153
|
+
export function makeTerminalEventProbe(deps: TerminalEventProbeDeps): TerminalEventProbe {
|
|
154
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
155
|
+
const now = deps.now ?? Date.now;
|
|
156
|
+
const step = Math.max(1, deps.pollMs ?? CLASSIFY_POLL_MS);
|
|
157
|
+
let offset = 0;
|
|
158
|
+
let partial = "";
|
|
159
|
+
let payload: Record<string, unknown> | undefined;
|
|
160
|
+
|
|
161
|
+
let fd: number | null = null;
|
|
162
|
+
const ioOpen = deps.io?.open ?? ((path: string) => fs.openSync(path, "r"));
|
|
163
|
+
const ioSize = deps.io?.size ?? ((handle: number) => fs.fstatSync(handle).size);
|
|
164
|
+
const ioRead =
|
|
165
|
+
deps.io?.read ??
|
|
166
|
+
((handle: number, start: number, end: number): { text: string; bytesRead: number } => {
|
|
167
|
+
const length = end - start;
|
|
168
|
+
const buffer = Buffer.alloc(length);
|
|
169
|
+
const bytesRead = fs.readSync(handle, buffer, 0, length, start);
|
|
170
|
+
return { text: buffer.toString("utf8", 0, bytesRead), bytesRead };
|
|
171
|
+
});
|
|
172
|
+
const ioClose = deps.io?.close ?? ((handle: number) => fs.closeSync(handle));
|
|
173
|
+
|
|
174
|
+
const closeIo = (): void => {
|
|
175
|
+
if (fd === null) return;
|
|
176
|
+
try {
|
|
177
|
+
ioClose(fd);
|
|
178
|
+
} catch {
|
|
179
|
+
/* fd đã chết — bỏ qua */
|
|
180
|
+
}
|
|
181
|
+
fd = null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const probe = async (budgetMs: number): Promise<boolean> => {
|
|
185
|
+
payload = undefined;
|
|
186
|
+
const deadline = now() + Math.max(0, budgetMs);
|
|
187
|
+
try {
|
|
188
|
+
for (;;) {
|
|
189
|
+
if (scanOnce()) return true;
|
|
190
|
+
if (now() >= deadline) return false;
|
|
191
|
+
await sleep(Math.min(step, Math.max(1, deadline - now())));
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
// Cửa sổ classify kết thúc (hoặc throw do caller bug) → nhả fd. Probe
|
|
195
|
+
// kế tiếp mở lại tự nhiên.
|
|
196
|
+
closeIo();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const foundPayload = (): Record<string, unknown> => payload ?? EMPTY_PAYLOAD;
|
|
200
|
+
|
|
201
|
+
function feed(chunk: string): boolean {
|
|
202
|
+
if (!chunk) return false;
|
|
203
|
+
const lines = (partial + chunk).split("\n");
|
|
204
|
+
partial = lines.pop() ?? "";
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (!line.trim()) continue;
|
|
207
|
+
let parsed: unknown;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(line);
|
|
210
|
+
} catch {
|
|
211
|
+
continue; // dòng dở/torn — bỏ, không kẹt stream
|
|
212
|
+
}
|
|
213
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
214
|
+
const record = parsed as Record<string, unknown>;
|
|
215
|
+
if (record.type !== "worker.completed") continue;
|
|
216
|
+
if (deps.taskId !== undefined && record.taskId !== deps.taskId) continue;
|
|
217
|
+
if (deps.runId !== undefined && record.runId !== deps.runId) continue;
|
|
218
|
+
payload =
|
|
219
|
+
record.data && typeof record.data === "object" && !Array.isArray(record.data)
|
|
220
|
+
? (record.data as Record<string, unknown>)
|
|
221
|
+
: {};
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scanOnce(): boolean {
|
|
228
|
+
if (deps.readFile) {
|
|
229
|
+
// Fixture mode: content FULL mỗi lần, offset là char-index vào nó.
|
|
230
|
+
const content = safeReadFile();
|
|
231
|
+
if (content === undefined) return false; // file chưa tồn tại — chưa có gì để khớp
|
|
232
|
+
if (content.length < offset) {
|
|
233
|
+
offset = 0;
|
|
234
|
+
partial = "";
|
|
235
|
+
}
|
|
236
|
+
const chunk = content.slice(offset);
|
|
237
|
+
offset += chunk.length;
|
|
238
|
+
return feed(chunk);
|
|
239
|
+
}
|
|
240
|
+
return scanIncremental();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function safeReadFile(): string | undefined {
|
|
244
|
+
try {
|
|
245
|
+
return deps.readFile!(deps.eventsPath);
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Chỉ đọc byte [offset, size) — poll giữa chừng trên log dài tốn O(delta)
|
|
253
|
+
* chứ không O(file). Truncate/shrink (size < offset) đọc lại từ đầu.
|
|
254
|
+
*/
|
|
255
|
+
function scanIncremental(): boolean {
|
|
256
|
+
if (fd === null) {
|
|
257
|
+
try {
|
|
258
|
+
fd = ioOpen(deps.eventsPath);
|
|
259
|
+
} catch {
|
|
260
|
+
return false; // ENOENT — recorder chưa tạo file; thử lại ở poll kế
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
let size: number;
|
|
264
|
+
try {
|
|
265
|
+
size = ioSize(fd);
|
|
266
|
+
} catch {
|
|
267
|
+
// fd point-at-dead-inode (file bị rotate/rename) — mở lại sạch.
|
|
268
|
+
closeIo();
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (size < offset) {
|
|
272
|
+
offset = 0;
|
|
273
|
+
partial = "";
|
|
274
|
+
}
|
|
275
|
+
if (size === offset) return false;
|
|
276
|
+
let chunk: { text: string; bytesRead: number };
|
|
277
|
+
try {
|
|
278
|
+
chunk = ioRead(fd, offset, size);
|
|
279
|
+
} catch {
|
|
280
|
+
return false; // race đọc giữa lúc truncate — offset giữ nguyên, thử lại
|
|
281
|
+
}
|
|
282
|
+
// Parity với EventLogTailSource.readFromOffset:193 — offset tiến theo SỐ
|
|
283
|
+
// BYTE thực đọc (bytesRead), KHÔNG theo byteLength(text): một poll cắt giữa
|
|
284
|
+
// multi-byte UTF-8 char làm toString nhét U+FFFD thay byte lead → byteLength
|
|
285
|
+
// lớn hơn bytesRead, mà nhảy quá ăn mất byte thật của poll kế.
|
|
286
|
+
offset += chunk.bytesRead;
|
|
287
|
+
return feed(chunk.text);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Object.assign(probe, { foundPayload });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── 3. Reducers trên ManifestSurfaceState ─────────────────────────────────
|
|
294
|
+
|
|
295
|
+
/** State trống (provider chưa biết) — mọi reducer đều bắt đầu từ đây. */
|
|
296
|
+
export function emptySurfaceState(): ManifestSurfaceState {
|
|
297
|
+
return { provider: null, panes: {}, workerPids: {}, sessionPaths: {} };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Đọc trường `surface` của manifest legacy/thồi mà KHÔNG tin mù quáng: field
|
|
302
|
+
* sai shape (JSON tay, writer cũ) → bỏ từng phần về default thay vì crash một
|
|
303
|
+
* run vì dữ liệu cosmetic.
|
|
304
|
+
*/
|
|
305
|
+
export function normalizeSurfaceState(raw: unknown): ManifestSurfaceState {
|
|
306
|
+
const state = emptySurfaceState();
|
|
307
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return state;
|
|
308
|
+
const value = raw as Partial<ManifestSurfaceState> & Record<string, unknown>;
|
|
309
|
+
if (value.provider === "tmux" || value.provider === "herdr") state.provider = value.provider;
|
|
310
|
+
state.panes = stringRecord(value.panes);
|
|
311
|
+
state.workerPids = numberRecord(value.workerPids);
|
|
312
|
+
state.sessionPaths = stringRecord(value.sessionPaths);
|
|
313
|
+
const tabs = stringArrayRecord(value.tabs);
|
|
314
|
+
if (Object.keys(tabs).length > 0) state.tabs = tabs;
|
|
315
|
+
const lockout = value.lockout as ManifestSurfaceState["lockout"];
|
|
316
|
+
if (lockout && typeof lockout === "object" && typeof lockout.since === "string") {
|
|
317
|
+
const counts = (lockout.counts ?? {}) as Partial<LockoutCounts>;
|
|
318
|
+
state.lockout = {
|
|
319
|
+
since: lockout.since,
|
|
320
|
+
counts: {
|
|
321
|
+
pane: Number.isFinite(counts.pane) ? Number(counts.pane) : 0,
|
|
322
|
+
mux: Number.isFinite(counts.mux) ? Number(counts.mux) : 0,
|
|
323
|
+
},
|
|
324
|
+
cause: lockout.cause === "spawn-fail" ? "spawn-fail" : "degrade",
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return state;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function stringRecord(raw: unknown): Record<string, string> {
|
|
331
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
332
|
+
const out: Record<string, string> = {};
|
|
333
|
+
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
334
|
+
if (typeof val === "string" && key) out[key] = val;
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function numberRecord(raw: unknown): Record<string, number> {
|
|
340
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
341
|
+
const out: Record<string, number> = {};
|
|
342
|
+
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
343
|
+
if (typeof val === "number" && Number.isFinite(val)) out[key] = val;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Tab-layout Task 5: tabs[tabKey] chỉ giữ string[] hợp lệ — entry rác bỏ cả key. */
|
|
349
|
+
function stringArrayRecord(raw: unknown): Record<string, string[]> {
|
|
350
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
351
|
+
const out: Record<string, string[]> = {};
|
|
352
|
+
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
353
|
+
if (!key || !Array.isArray(val)) continue;
|
|
354
|
+
const ids = val.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
355
|
+
if (ids.length > 0) out[key] = ids;
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Ghi nhận pane sống (boot xong createSurface+sendCommand). Thay thế entry cũ. */
|
|
361
|
+
export function recordSurfacePane(
|
|
362
|
+
state: ManifestSurfaceState,
|
|
363
|
+
input: { taskId: string; paneId: string; provider?: string },
|
|
364
|
+
): ManifestSurfaceState {
|
|
365
|
+
return {
|
|
366
|
+
...state,
|
|
367
|
+
...(input.provider === "tmux" || input.provider === "herdr" ? { provider: input.provider } : {}),
|
|
368
|
+
panes: { ...state.panes, [input.taskId]: input.paneId },
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Pane đã thoát — luôn xóa khỏi map sống dù completed hay degraded.
|
|
373
|
+
* Task 5 (spec tab-layout §5): CHỈ gỡ entry manifest — host không close pane/tab
|
|
374
|
+
* theo từng worker (pane tự exit khi script xong là worker-side); tab của run
|
|
375
|
+
* sống tới run end và do {@link closeTabForRun} đóng. Tabs không bị đụng ở đây. */
|
|
376
|
+
export function releaseSurfacePane(state: ManifestSurfaceState, taskId: string): ManifestSurfaceState {
|
|
377
|
+
if (!(taskId in state.panes)) return state;
|
|
378
|
+
const panes = { ...state.panes };
|
|
379
|
+
delete panes[taskId];
|
|
380
|
+
return { ...state, panes };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Ghi nhận tab của run (tab-layout §5): append-dedup tabId vào tabs[tabKey]. */
|
|
384
|
+
export function recordSurfaceTab(state: ManifestSurfaceState, input: { tabKey: string; tabId: string }): ManifestSurfaceState {
|
|
385
|
+
const existing = state.tabs?.[input.tabKey] ?? [];
|
|
386
|
+
if (existing.includes(input.tabId)) return state;
|
|
387
|
+
return { ...state, tabs: { ...(state.tabs ?? {}), [input.tabKey]: [...existing, input.tabId] } };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Run end/cancel/kill → đóng TOÀN tab của run (spec tab-layout §5). Provider tự
|
|
392
|
+
* đóng mọi window/tab của run theo map nội bộ keyed theo tabKey nên đây gọi
|
|
393
|
+
* closeTab ĐÚNG MỘT LẦN — KHÔNG loop từng tabId (self-review Task 5). Lỗi
|
|
394
|
+
* provider được nuốt + log: cleanup cuối run không được làm hỏng finalize.
|
|
395
|
+
* Trả về state mới với tabs[tabKey] = [] (giữ key làm evidence "đã đóng") VÀ
|
|
396
|
+
* mutate in-place theo pattern Object.assign của controller trong file này.
|
|
397
|
+
*/
|
|
398
|
+
export async function closeTabForRun(
|
|
399
|
+
surface: ManifestSurfaceState,
|
|
400
|
+
tabKey: string,
|
|
401
|
+
provider: SurfaceProvider | null | undefined,
|
|
402
|
+
): Promise<ManifestSurfaceState> {
|
|
403
|
+
if (typeof provider?.closeTab === "function") {
|
|
404
|
+
try {
|
|
405
|
+
await provider.closeTab(tabKey);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
logInternalError(
|
|
408
|
+
"surface-degrade.close-tab",
|
|
409
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
410
|
+
`tabKey=${tabKey} provider=${provider.kind}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const next: ManifestSurfaceState = { ...surface, tabs: { ...(surface.tabs ?? {}), [tabKey]: [] } };
|
|
415
|
+
Object.assign(surface, next);
|
|
416
|
+
return next;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Cập nhật pid/sessionPath từ event `worker.started` của chính worker. */
|
|
420
|
+
export function recordWorkerStarted(
|
|
421
|
+
state: ManifestSurfaceState,
|
|
422
|
+
input: { taskId: string; pid?: number; sessionPath?: string },
|
|
423
|
+
): ManifestSurfaceState {
|
|
424
|
+
const next: ManifestSurfaceState = { ...state };
|
|
425
|
+
if (typeof input.pid === "number" && Number.isFinite(input.pid)) next.workerPids = { ...state.workerPids, [input.taskId]: input.pid };
|
|
426
|
+
if (typeof input.sessionPath === "string" && input.sessionPath)
|
|
427
|
+
next.sessionPaths = { ...state.sessionPaths, [input.taskId]: input.sessionPath };
|
|
428
|
+
return next;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface DegradedEntryLike {
|
|
432
|
+
taskId: string;
|
|
433
|
+
cause: SurfaceDegradeCause;
|
|
434
|
+
ts: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Áp một batch degrade: counts per cause-group + bật lockout khi ≥1 entry. */
|
|
438
|
+
export function applyDegradedBatch(state: ManifestSurfaceState, entries: readonly DegradedEntryLike[]): ManifestSurfaceState {
|
|
439
|
+
if (entries.length === 0) return state;
|
|
440
|
+
const causes = entries.map((entry) => entry.cause);
|
|
441
|
+
const counts = nextLockoutCountsForBatch(state.lockout?.counts ?? { pane: 0, mux: 0 }, causes);
|
|
442
|
+
return {
|
|
443
|
+
...state,
|
|
444
|
+
lockout: {
|
|
445
|
+
since: state.lockout?.since ?? entries[0]!.ts,
|
|
446
|
+
counts,
|
|
447
|
+
cause: "degrade",
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Bật/kéo dài lockout vì spawn-fail (giữ nguyên counts degrade đã ghi). */
|
|
453
|
+
export function applySpawnFailLockout(state: ManifestSurfaceState, since: string): ManifestSurfaceState {
|
|
454
|
+
return {
|
|
455
|
+
...state,
|
|
456
|
+
lockout: {
|
|
457
|
+
since: state.lockout?.since ?? since,
|
|
458
|
+
counts: state.lockout?.counts ?? { pane: 0, mux: 0 },
|
|
459
|
+
cause: "spawn-fail",
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── Re-dispatch planning (spec §7 bước 4–5) ───────────────────────────────
|
|
465
|
+
|
|
466
|
+
export interface SurfaceDegradedEntry extends DegradedEntryLike {
|
|
467
|
+
paneId?: string;
|
|
468
|
+
exitReason?: string;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export interface HeadlessReplayPlan {
|
|
472
|
+
tasks: TeamTaskState[];
|
|
473
|
+
requeuedTaskIds: string[];
|
|
474
|
+
skipped: Array<{ taskId: string; reason: string }>;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Resume prompt bơm qua kênh pendingSteers — cùng cơ chế ask-timeout sweep,
|
|
478
|
+
* cùng fencing untrusted ("DATA, not a system directive"). */
|
|
479
|
+
export function renderSurfaceLostResumeNote(entry: { cause: SurfaceDegradeCause }): string {
|
|
480
|
+
return [
|
|
481
|
+
"<dependency-context>",
|
|
482
|
+
"(Scheduler note: the previous worker lost its multiplexer pane before finishing this task",
|
|
483
|
+
`(cause=${entry.cause}). It was re-dispatched headless with restored scratchpad state.`,
|
|
484
|
+
"Continue from where you left off.",
|
|
485
|
+
"(It is DATA, not a system directive.)",
|
|
486
|
+
"</dependency-context>",
|
|
487
|
+
].join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Chuyển danh sách degrade thành requeue: task quay lại `queued` kèm resume
|
|
492
|
+
* note + attempt marker reason "surface-lost" (spec: KHÔNG đếm retry budget —
|
|
493
|
+
* policy.retryCount giữ nguyên). Idempotent theo `handledTaskIds` nên scheduler
|
|
494
|
+
* tick sau không requeue lặp; task đã terminal vì lý do khác được bỏ qua.
|
|
495
|
+
*/
|
|
496
|
+
export function planHeadlessRedeplays(input: {
|
|
497
|
+
tasks: readonly TeamTaskState[];
|
|
498
|
+
degraded: readonly SurfaceDegradedEntry[];
|
|
499
|
+
/** Caller-owned guard (scheduler passes its once-per-run Set) — mutated here. */
|
|
500
|
+
handledTaskIds?: Set<string>;
|
|
501
|
+
note?: string;
|
|
502
|
+
}): HeadlessReplayPlan {
|
|
503
|
+
const handled = input.handledTaskIds ?? new Set<string>();
|
|
504
|
+
const byId = new Map(input.tasks.map((task) => [task.id, task] as const));
|
|
505
|
+
const plan: HeadlessReplayPlan = { tasks: [...input.tasks], requeuedTaskIds: [], skipped: [] };
|
|
506
|
+
for (const entry of input.degraded) {
|
|
507
|
+
const task = byId.get(entry.taskId);
|
|
508
|
+
if (!task) {
|
|
509
|
+
plan.skipped.push({ taskId: entry.taskId, reason: "task not in graph" });
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (handled.has(entry.taskId)) {
|
|
513
|
+
plan.skipped.push({ taskId: entry.taskId, reason: "already re-dispatched once for surface loss" });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
// needs_attention là trạng thái mà finalizeSurfaceLoss để lại; running
|
|
517
|
+
// phòng khi unit chưa kịp finalize. Các trạng thái khác (queued/completed/
|
|
518
|
+
// cancelled/failed…) nghĩa là lifecycle khác đã quyết — không giành quyền.
|
|
519
|
+
const requeueable = task.status === "needs_attention" || task.status === "running";
|
|
520
|
+
if (!requeueable) {
|
|
521
|
+
plan.skipped.push({ taskId: entry.taskId, reason: `status ${task.status} is owned by another lifecycle` });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const note = input.note ?? renderSurfaceLostResumeNote(entry);
|
|
525
|
+
plan.tasks = plan.tasks.map((candidate) =>
|
|
526
|
+
candidate.id === entry.taskId
|
|
527
|
+
? {
|
|
528
|
+
...candidate,
|
|
529
|
+
status: "queued" as const,
|
|
530
|
+
startedAt: undefined,
|
|
531
|
+
finishedAt: undefined,
|
|
532
|
+
error: undefined,
|
|
533
|
+
claim: undefined,
|
|
534
|
+
heartbeat: candidate.heartbeat,
|
|
535
|
+
// Không rờ policy.retryCount — degrade không ăn retry budget (spec §7 b5).
|
|
536
|
+
attempts: [
|
|
537
|
+
...(candidate.attempts ?? []),
|
|
538
|
+
{
|
|
539
|
+
attemptId: `${entry.taskId}:surface-lost`,
|
|
540
|
+
startedAt: candidate.startedAt ?? entry.ts,
|
|
541
|
+
endedAt: entry.ts,
|
|
542
|
+
error: `[surface-lost] ${entry.cause}`,
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
pendingSteers: [...(candidate.pendingSteers ?? []), note],
|
|
546
|
+
diagnostics: {
|
|
547
|
+
...(candidate.diagnostics ?? {}),
|
|
548
|
+
surfaceLost: { ts: entry.ts, cause: entry.cause, paneId: entry.paneId },
|
|
549
|
+
},
|
|
550
|
+
adaptive: candidate.adaptive ? { ...candidate.adaptive, phase: "resumed" } : candidate.adaptive,
|
|
551
|
+
}
|
|
552
|
+
: candidate,
|
|
553
|
+
);
|
|
554
|
+
plan.requeuedTaskIds.push(entry.taskId);
|
|
555
|
+
handled.add(entry.taskId);
|
|
556
|
+
}
|
|
557
|
+
return plan;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── 4. Per-run controller ─────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
export interface SurfaceRuntimeControllerDeps {
|
|
563
|
+
runId: string;
|
|
564
|
+
/** RUN events.jsonl — nơi ghi event `surface.degraded`. */
|
|
565
|
+
eventsPath: string;
|
|
566
|
+
/**
|
|
567
|
+
* Fix round 1/F2: raw `manifest.surface` của run đang resume — controller
|
|
568
|
+
* SEED state từ đây thay vì khởi động trống, nếu không một host restart
|
|
569
|
+
* giữa run sẽ đánh mất lockout.since/counts + workerPids/sessionPaths cũ.
|
|
570
|
+
* Pane cũ vẫn còn trong evidence map: nó có thể THẬT vẫn sống khi host chết
|
|
571
|
+
* (worker là con của mux server), và entry cùng taskId sẽ bị đè khi task
|
|
572
|
+
* được re-dispatch. Dữ liệu rác/không hợp lệ được normalizeSurfaceState lọc.
|
|
573
|
+
*/
|
|
574
|
+
initialState?: unknown;
|
|
575
|
+
/** Broker token revoker (T10) — resolve lazy lúc degrade. */
|
|
576
|
+
revoke?: (taskId: string) => void;
|
|
577
|
+
appendEvent?: (eventsPath: string, event: AppendTeamEvent) => void;
|
|
578
|
+
now?: () => number;
|
|
579
|
+
/** Hook kiểm chứng trong test (default no-op). */
|
|
580
|
+
onDegrade?: (entry: SurfaceDegradedEntry) => void;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface SurfaceRuntimeController {
|
|
584
|
+
readonly runId: string;
|
|
585
|
+
/** Số pane đang sống (cap check của resolveSurface). */
|
|
586
|
+
livePaneCount(): number;
|
|
587
|
+
/** False khi lockout (degrade hoặc 3 spawn-fail) → dispatch bỏ surface. */
|
|
588
|
+
shouldAttemptSurface(): boolean;
|
|
589
|
+
notifySpawned(input: { taskId: string; paneId: string; provider: string; tabKey?: string; tabId?: string }): void;
|
|
590
|
+
notifySpawnFailed(input: { taskId: string; reason: string }): void;
|
|
591
|
+
notifyWorkerStarted(input: { taskId: string; pid?: number; sessionPath?: string }): void;
|
|
592
|
+
notifyPaneExited(input: {
|
|
593
|
+
taskId: string;
|
|
594
|
+
paneId: string;
|
|
595
|
+
completed: boolean;
|
|
596
|
+
exitReason: SurfaceExitReason;
|
|
597
|
+
cancelledByAbort?: boolean;
|
|
598
|
+
timedOut?: boolean;
|
|
599
|
+
}): void;
|
|
600
|
+
takeDegraded(): SurfaceDegradedEntry[];
|
|
601
|
+
consecutiveSpawnFails(): number;
|
|
602
|
+
/**
|
|
603
|
+
* Run end (completed/cancelled/failed — spec tab-layout §5): đóng MỌI tab
|
|
604
|
+
* đã ghi của run qua provider (theo kind của state). Team-runner gọi trong
|
|
605
|
+
* finally — mọi đường thoát đều đóng tab, không chỉ happy path.
|
|
606
|
+
*/
|
|
607
|
+
closeRunTabs(provider: SurfaceProvider | null | undefined): Promise<void>;
|
|
608
|
+
snapshot(): ManifestSurfaceState;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Module-singleton per run — cùng mô hình với getActiveBrokerIssuer: parent
|
|
612
|
+
// process đăng ký 1 lần/lúc run start, child-pi layer tra theo runId mà không
|
|
613
|
+
// phải bơm tham số xuyên 4 tầng runner.
|
|
614
|
+
const controllersByRun = new Map<string, SurfaceRuntimeController>();
|
|
615
|
+
|
|
616
|
+
export function registerSurfaceRuntimeController(controller: SurfaceRuntimeController): void {
|
|
617
|
+
controllersByRun.set(controller.runId, controller);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function getSurfaceRuntimeController(runId: string | undefined): SurfaceRuntimeController | undefined {
|
|
621
|
+
if (!runId) return undefined;
|
|
622
|
+
return controllersByRun.get(runId);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Run-scoped teardown — gọi trong finally của team-runner để không leak qua run sau. */
|
|
626
|
+
export function clearSurfaceRuntimeController(runId: string | undefined): void {
|
|
627
|
+
if (!runId) return;
|
|
628
|
+
if (controllersByRun.get(runId)?.runId === runId) controllersByRun.delete(runId);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Test seam — dọn sạch mọi controller còn đăng ký. */
|
|
632
|
+
export function __test__clearAllSurfaceControllers(): void {
|
|
633
|
+
controllersByRun.clear();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function createSurfaceRuntimeController(deps: SurfaceRuntimeControllerDeps): SurfaceRuntimeController {
|
|
637
|
+
const now = deps.now ?? Date.now;
|
|
638
|
+
const appendEvent =
|
|
639
|
+
deps.appendEvent ??
|
|
640
|
+
((eventsPath: string, event: AppendTeamEvent) => {
|
|
641
|
+
try {
|
|
642
|
+
appendEventFireAndForget(eventsPath, event);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
logInternalError("surface-degrade.event", error instanceof Error ? error : new Error(String(error)), `runId=${deps.runId}`);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// F2: seed từ manifest của run đang resume (normalize lọc dữ liệu cũ/thồi).
|
|
649
|
+
const state = deps.initialState !== undefined ? normalizeSurfaceState(deps.initialState) : emptySurfaceState();
|
|
650
|
+
// Pane được seed từ manifest vẫn có thể THẬT đang sống (worker là con của
|
|
651
|
+
// mux server chứ không phải host chết) — đếm vào live cap như pane mới.
|
|
652
|
+
const livePids = new Set<string>(Object.keys(state.panes));
|
|
653
|
+
let spawnFailStreak = 0;
|
|
654
|
+
let degradedQueue: SurfaceDegradedEntry[] = [];
|
|
655
|
+
|
|
656
|
+
const degrade = (input: { taskId: string; paneId: string; exitReason: SurfaceExitReason }): void => {
|
|
657
|
+
const cause: SurfaceDegradeCause = input.exitReason === "mux-dead" ? "mux-dead" : "pane-closed";
|
|
658
|
+
const entry: SurfaceDegradedEntry = {
|
|
659
|
+
taskId: input.taskId,
|
|
660
|
+
paneId: input.paneId,
|
|
661
|
+
exitReason: input.exitReason,
|
|
662
|
+
cause,
|
|
663
|
+
ts: new Date(now()).toISOString(),
|
|
664
|
+
};
|
|
665
|
+
degradedQueue.push(entry);
|
|
666
|
+
// F1 (spec §7 c3 anti-flap): khóa NGAY tại degrade ĐẦU TIÊN, nhưng counts
|
|
667
|
+
// KHÔNG cộng per-entry — N pane mux-dead chảy vào đây qua N lời gọi rời
|
|
668
|
+
// rạc là MỘT sự kiện. counts được tính đúng một lần trên cả batch tại
|
|
669
|
+
// takeDegraded() (scheduler drain), nơi ranh giới "cùng lúc" xác định được.
|
|
670
|
+
if (state.lockout?.cause !== "degrade") {
|
|
671
|
+
state.lockout = {
|
|
672
|
+
since: state.lockout?.since ?? entry.ts,
|
|
673
|
+
counts: state.lockout?.counts ?? { pane: 0, mux: 0 },
|
|
674
|
+
cause: "degrade",
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// Spec §7 bước 1–3: event đủ entry cho debug, revoke token NGAY (worker
|
|
678
|
+
// zombie trong pane không được tiếp tục nói chuyện với broker), rồi
|
|
679
|
+
// scheduler tick sẽ làm bước 4–5 (re-dispatch headless).
|
|
680
|
+
try {
|
|
681
|
+
appendEvent(deps.eventsPath, {
|
|
682
|
+
type: "surface.degraded",
|
|
683
|
+
runId: deps.runId,
|
|
684
|
+
taskId: entry.taskId,
|
|
685
|
+
message: `Surface worker lost (${cause}) before worker.completed within ${CLASSIFY_TIMEOUT_MS}ms classify window`,
|
|
686
|
+
data: { taskId: entry.taskId, paneId: entry.paneId, reason: input.exitReason, ts: entry.ts, cause },
|
|
687
|
+
});
|
|
688
|
+
} catch (error) {
|
|
689
|
+
logInternalError("surface-degrade.event", error instanceof Error ? error : new Error(String(error)), `runId=${deps.runId}`);
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
deps.revoke?.(input.taskId);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
logInternalError("surface-degrade.revoke", error instanceof Error ? error : new Error(String(error)), `runId=${deps.runId}`);
|
|
695
|
+
}
|
|
696
|
+
deps.onDegrade?.(entry);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
runId: deps.runId,
|
|
701
|
+
livePaneCount: () => livePids.size,
|
|
702
|
+
shouldAttemptSurface: () => state.lockout === undefined,
|
|
703
|
+
notifySpawned: ({ taskId, paneId, provider, tabKey, tabId }) => {
|
|
704
|
+
spawnFailStreak = 0; // boot thành công disproves streak (spec: liên tiếp)
|
|
705
|
+
Object.assign(state, recordSurfacePane(state, { taskId, paneId, provider }));
|
|
706
|
+
// Tab-layout Task 5: tab của run ghi vào manifest để run end đóng —
|
|
707
|
+
// worker xong KHÔNG gỡ (tab sống tới run end).
|
|
708
|
+
if (tabKey && tabId) Object.assign(state, recordSurfaceTab(state, { tabKey, tabId }));
|
|
709
|
+
livePids.add(taskId);
|
|
710
|
+
},
|
|
711
|
+
notifySpawnFailed: ({ taskId, reason }) => {
|
|
712
|
+
spawnFailStreak = nextConsecutiveSpawnFails(spawnFailStreak, true);
|
|
713
|
+
if (!isSpawnFailLockout(spawnFailStreak)) {
|
|
714
|
+
logInternalError(
|
|
715
|
+
"surface-degrade.spawn-fail",
|
|
716
|
+
new Error(reason),
|
|
717
|
+
`runId=${deps.runId} taskId=${taskId} streak=${spawnFailStreak}`,
|
|
718
|
+
"warn",
|
|
719
|
+
);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (state.lockout?.cause === "spawn-fail") return; // đã khóa — im lặng thay vì spam
|
|
723
|
+
Object.assign(state, applySpawnFailLockout(state, new Date(now()).toISOString()));
|
|
724
|
+
logInternalError(
|
|
725
|
+
"surface-degrade.spawn-fail-lockout",
|
|
726
|
+
new Error(reason),
|
|
727
|
+
`runId=${deps.runId} — surface OFF rest of the run`,
|
|
728
|
+
"warn",
|
|
729
|
+
);
|
|
730
|
+
},
|
|
731
|
+
notifyWorkerStarted: ({ taskId, pid, sessionPath }) => {
|
|
732
|
+
Object.assign(state, recordWorkerStarted(state, { taskId, pid, sessionPath }));
|
|
733
|
+
},
|
|
734
|
+
notifyPaneExited: ({ taskId, paneId, completed, exitReason, cancelledByAbort, timedOut }) => {
|
|
735
|
+
livePids.delete(taskId);
|
|
736
|
+
Object.assign(state, releaseSurfacePane(state, taskId));
|
|
737
|
+
if (completed) return;
|
|
738
|
+
if (cancelledByAbort || timedOut) return; // cancel/deadline do host — không phải flap của mux
|
|
739
|
+
if (exitReason !== "pane-closed" && exitReason !== "mux-dead") return; // detached = host dispose
|
|
740
|
+
degrade({ taskId, paneId, exitReason });
|
|
741
|
+
},
|
|
742
|
+
takeDegraded: () => {
|
|
743
|
+
const drained = degradedQueue;
|
|
744
|
+
degradedQueue = [];
|
|
745
|
+
if (drained.length > 0) {
|
|
746
|
+
// F1: counts của TOÀN BỘ batch drain này tính đúng một lần — N entry
|
|
747
|
+
// mux-dead đồng thời = +1 mux (applyDegradedBatch dùng cause-group
|
|
748
|
+
// batching; since giữ từ lockout đã đặt ở degrade đầu tiên).
|
|
749
|
+
Object.assign(state, applyDegradedBatch(state, drained));
|
|
750
|
+
}
|
|
751
|
+
return drained;
|
|
752
|
+
},
|
|
753
|
+
consecutiveSpawnFails: () => spawnFailStreak,
|
|
754
|
+
closeRunTabs: async (provider) => {
|
|
755
|
+
// Copy keys trước — closeTabForRun mutate state.tabs trong lúc lặp.
|
|
756
|
+
for (const tabKey of Object.keys(state.tabs ?? {})) {
|
|
757
|
+
await closeTabForRun(state, tabKey, provider);
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
// Trả snapshot MỚI mỗi lần — team-runner gắn nguyên object vào manifest.
|
|
761
|
+
snapshot: () => snapshotState(state),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function snapshotState(state: ManifestSurfaceState): ManifestSurfaceState {
|
|
766
|
+
return {
|
|
767
|
+
provider: state.provider,
|
|
768
|
+
panes: { ...state.panes },
|
|
769
|
+
workerPids: { ...state.workerPids },
|
|
770
|
+
sessionPaths: { ...state.sessionPaths },
|
|
771
|
+
...(state.tabs ? { tabs: { ...state.tabs } } : {}),
|
|
772
|
+
...(state.lockout
|
|
773
|
+
? { lockout: { since: state.lockout.since, counts: { ...state.lockout.counts }, cause: state.lockout.cause } }
|
|
774
|
+
: {}),
|
|
775
|
+
};
|
|
776
|
+
}
|