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.
Files changed (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. 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
+ }