@selesai/code 0.13.39 → 0.13.40

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 (82) hide show
  1. package/dist/core/intercom-rpc.d.ts +135 -0
  2. package/dist/core/intercom-rpc.js +64 -0
  3. package/dist/core/release-rpc.d.ts +137 -0
  4. package/dist/core/release-rpc.js +77 -0
  5. package/dist/core/resource-loader.d.ts +3 -0
  6. package/dist/core/resource-loader.js +3 -0
  7. package/dist/core/subagent-rpc.d.ts +110 -0
  8. package/dist/core/subagent-rpc.js +60 -0
  9. package/dist/extensions/agent-browser.ts +9 -0
  10. package/dist/extensions/pi-intercom/README.md +15 -0
  11. package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
  12. package/dist/extensions/pi-intercom/broker/client.ts +6 -2
  13. package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
  14. package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
  15. package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
  16. package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
  17. package/dist/extensions/pi-intercom/extension-api.ts +10 -0
  18. package/dist/extensions/pi-intercom/index.ts +188 -8
  19. package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
  20. package/dist/extensions/pi-intercom/package.json +1 -1
  21. package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
  22. package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
  23. package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
  24. package/dist/extensions/pi-subagents/docs/observability.md +8 -0
  25. package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
  26. package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
  27. package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
  28. package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
  29. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
  30. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
  31. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
  32. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
  33. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
  34. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
  35. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
  36. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
  37. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
  38. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
  39. package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
  40. package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
  41. package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
  42. package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
  43. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
  44. package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
  45. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
  46. package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
  47. package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
  48. package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
  49. package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
  50. package/dist/extensions/tokenin-onboarding.ts +4 -2
  51. package/dist/index.d.ts +6 -1
  52. package/dist/index.js +7 -0
  53. package/dist/main.d.ts +6 -0
  54. package/dist/main.js +1 -1
  55. package/dist/modes/index.d.ts +1 -1
  56. package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
  57. package/dist/modes/rpc/intercom-bridge.js +75 -0
  58. package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
  59. package/dist/modes/rpc/intercom-bridge.test.js +65 -0
  60. package/dist/modes/rpc/release-bridge.d.ts +35 -0
  61. package/dist/modes/rpc/release-bridge.js +159 -0
  62. package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
  63. package/dist/modes/rpc/release-bridge.test.js +347 -0
  64. package/dist/modes/rpc/rpc-client.d.ts +34 -10
  65. package/dist/modes/rpc/rpc-client.js +45 -2
  66. package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
  67. package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
  68. package/dist/modes/rpc/rpc-mode.d.ts +1 -13
  69. package/dist/modes/rpc/rpc-mode.js +239 -8
  70. package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
  71. package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
  72. package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
  73. package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
  74. package/dist/modes/rpc/rpc-types.d.ts +12 -2
  75. package/dist/modes/rpc/rpc-types.js +0 -6
  76. package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
  77. package/dist/modes/rpc/subagent-bridge.js +93 -0
  78. package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
  79. package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
  80. package/docs/rpc-subagent.schema.json +491 -0
  81. package/docs/rpc.md +210 -1
  82. package/package.json +2 -1
@@ -1,28 +1,288 @@
1
- import type { SubagentState } from "../../shared/types.ts";
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { ownerFileStem, parseOwnerRecord, readOwnerRecord } from "../../shared/owner-record.ts";
6
+ import { processLiveness, processStartKey, startKeyScheme, type ProcessLiveness } from "../../shared/process-identity.ts";
7
+ import { DIRS, type SubagentState } from "../../shared/types.ts";
2
8
 
3
9
  const MAX_CLAIMED_PREDECESSOR_SESSIONS = 8;
10
+ const DEFAULT_LIVENESS_TTL_MS = 2_000;
11
+ /** Initial claim plus re-claims after a dead claimer. Exhausting this fails safe (no takeover). */
12
+ const MAX_CLAIM_GENERATIONS = 4;
13
+ const MAX_TAKEN_RESULTS = 256;
14
+ const MAX_VERDICT_CACHE_ENTRIES = 64;
15
+ const OWNER_RECORD_GC_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const CLAIM_GC_HARD_AGE_MS = 30 * 24 * 60 * 60 * 1000;
17
+ const STALE_TEMP_AGE_MS = 10 * 60 * 1000;
18
+ const GC_INTERVAL_MS = 10 * 60 * 1000;
19
+ const GC_MAX_ENTRIES_EXAMINED = 256;
20
+ const GC_MAX_DELETIONS = 16;
21
+ const CLAIMS_DIR_NAME = "claims";
4
22
 
5
23
  type ResultDeliveryState = Pick<SubagentState, "currentSessionId" | "completionOwnerId">;
6
24
 
25
+ /** `dead` is the only verdict that permits takeover; `unknown` covers every kind of missing or ambiguous proof. */
26
+ export type OwnerVerdict = "dead" | "live" | "unknown";
27
+
28
+ export interface ResultTakeoverDeps {
29
+ /** Directory holding `<completionOwnerId>.json` owner records and `claims/`. Defaults to `DIRS.owners`, read per call. */
30
+ ownersDir?: string | (() => string);
31
+ hostname?: () => string;
32
+ processLiveness?: (pid: number) => ProcessLiveness;
33
+ processStartKey?: (pid: number) => string | undefined;
34
+ now?: () => number;
35
+ /** How long a per-owner verdict is reused before liveness is probed again (probing may spawn `ps`). */
36
+ livenessTtlMs?: number;
37
+ }
38
+
7
39
  export interface ResultDeliveryOwnership {
8
- owns(sessionId: string, completionOwnerId: unknown): boolean;
40
+ /**
41
+ * True when this process owns the result. Pass `runKey` (the result's run id) so a result left by a provably dead
42
+ * previous owner can be taken over; that call atomically claims it (exactly once across processes).
43
+ */
44
+ owns(sessionId: string, completionOwnerId: unknown, runKey?: string): boolean;
45
+ /** Side-effect-free twin of the takeover half of `owns`: would `owns(..., runKey)` succeed? */
46
+ canTakeOver(sessionId: string, completionOwnerId: unknown, runKey?: string): boolean;
9
47
  claimedSessionIds(): readonly string[];
10
48
  claimPredecessor(previousSessionFile: string | undefined, previousRuntimeSessionId: string | null): boolean;
11
49
  clear(): void;
12
50
  }
13
51
 
14
- export function createResultDeliveryOwnership(state: ResultDeliveryState): ResultDeliveryOwnership {
52
+ interface ClaimMarker {
53
+ claimedBy: string;
54
+ previousOwner: string;
55
+ claimedAt: number;
56
+ }
57
+
58
+ type MarkerRead = { state: "missing" } | { state: "invalid" } | { state: "ok"; claimedBy: string };
59
+
60
+ function errorCode(error: unknown): string | undefined {
61
+ return typeof error === "object" && error !== null && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
62
+ }
63
+
64
+ export function createResultDeliveryOwnership(state: ResultDeliveryState, deps: ResultTakeoverDeps = {}): ResultDeliveryOwnership {
15
65
  const claimed = new Map<string, string>();
66
+ /** runKey -> proof that this process already holds the claim; keeps `owns` a cheap read after the first call. */
67
+ const takenOver = new Map<string, { sessionId: string; previousOwner: string; by: string }>();
68
+ const verdicts = new Map<string, { verdict: OwnerVerdict; at: number }>();
69
+ const now = deps.now ?? Date.now;
70
+ const hostname = deps.hostname ?? os.hostname;
71
+ const livenessOf = deps.processLiveness ?? ((pid: number) => processLiveness(pid));
72
+ const startKeyOf = deps.processStartKey ?? processStartKey;
73
+ const livenessTtlMs = deps.livenessTtlMs ?? DEFAULT_LIVENESS_TTL_MS;
74
+ let lastGcAt = Number.NEGATIVE_INFINITY;
16
75
 
17
76
  const currentOwner = (): string | undefined => typeof state.completionOwnerId === "string" && state.completionOwnerId
18
77
  ? state.completionOwnerId
19
78
  : undefined;
79
+ const ownersDir = (): string => typeof deps.ownersDir === "function" ? deps.ownersDir() : deps.ownersDir ?? DIRS.owners;
80
+ const claimsDir = (): string => path.join(ownersDir(), CLAIMS_DIR_NAME);
81
+
82
+ /** Same session condition the in-process rule has always used; takeover never widens it. */
83
+ const sessionIsOurs = (sessionId: string, owner: string): boolean =>
84
+ sessionId === state.currentSessionId || claimed.get(sessionId) === owner;
85
+
86
+ const ownerVerdictFromDisk = (ownerId: string): OwnerVerdict => {
87
+ const record = readOwnerRecord(ownersDir(), ownerId);
88
+ if (!record) return "unknown";
89
+ return recordVerdict(record.pid, record.startKey, record.hostname);
90
+ };
91
+ const recordVerdict = (pid: number, recordedStartKey: string | undefined, recordedHostname: string): OwnerVerdict => {
92
+ if (recordedHostname !== hostname()) return "unknown";
93
+ const liveness = livenessOf(pid);
94
+ if (liveness === "dead") return "dead";
95
+ if (liveness !== "alive") return "unknown";
96
+ if (recordedStartKey) {
97
+ const current = startKeyOf(pid);
98
+ // A pid reused by another process has a different start time. Keys from different schemes (for example
99
+ // `/proc` vs `ps`) are not comparable, so a scheme change proves nothing.
100
+ if (current && startKeyScheme(current) === startKeyScheme(recordedStartKey) && current !== recordedStartKey) return "dead";
101
+ }
102
+ return "live";
103
+ };
104
+ const verdictOf = (ownerId: string): OwnerVerdict => {
105
+ const at = now();
106
+ const cached = verdicts.get(ownerId);
107
+ if (cached && at >= cached.at && at - cached.at < livenessTtlMs) return cached.verdict;
108
+ let verdict: OwnerVerdict;
109
+ try {
110
+ verdict = ownerVerdictFromDisk(ownerId);
111
+ } catch {
112
+ verdict = "unknown";
113
+ }
114
+ verdicts.delete(ownerId);
115
+ verdicts.set(ownerId, { verdict, at });
116
+ while (verdicts.size > MAX_VERDICT_CACHE_ENTRIES) verdicts.delete(verdicts.keys().next().value!);
117
+ return verdict;
118
+ };
119
+
120
+ const claimPath = (runKey: string, generation: number): string | undefined => {
121
+ const stem = ownerFileStem(runKey);
122
+ // `%g` cannot occur in an encodeURIComponent() stem, so generation files never collide with another key.
123
+ return stem ? path.join(claimsDir(), generation <= 1 ? `${stem}.json` : `${stem}%g${generation}.json`) : undefined;
124
+ };
125
+ const readMarker = (file: string): MarkerRead => {
126
+ try {
127
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<ClaimMarker> | null;
128
+ return parsed && typeof parsed.claimedBy === "string" && parsed.claimedBy
129
+ ? { state: "ok", claimedBy: parsed.claimedBy }
130
+ : { state: "invalid" };
131
+ } catch (error) {
132
+ return errorCode(error) === "ENOENT" ? { state: "missing" } : { state: "invalid" };
133
+ }
134
+ };
135
+ /**
136
+ * Exclusive create with complete content: write a private temp file, then hard-link it into place (link fails
137
+ * with EEXIST if any claimant got there first, and a crash can never leave a half-written marker). Filesystems
138
+ * without hard links fall back to `wx`.
139
+ */
140
+ const createMarkerExclusively = (file: string, marker: ClaimMarker): boolean => {
141
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
142
+ const body = `${JSON.stringify(marker)}\n`;
143
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
144
+ fs.writeFileSync(temp, body, { encoding: "utf-8", mode: 0o600, flag: "wx" });
145
+ try {
146
+ fs.linkSync(temp, file);
147
+ return true;
148
+ } catch (error) {
149
+ const code = errorCode(error);
150
+ if (code === "EEXIST") return false;
151
+ if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP" && code !== "EXDEV") throw error;
152
+ try {
153
+ fs.writeFileSync(file, body, { encoding: "utf-8", mode: 0o600, flag: "wx" });
154
+ return true;
155
+ } catch (fallbackError) {
156
+ if (errorCode(fallbackError) === "EEXIST") return false;
157
+ throw fallbackError;
158
+ }
159
+ } finally {
160
+ fs.rmSync(temp, { force: true });
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Claim chain: generation 1 is created by the first taker; if its claimer is later proven dead, the next taker
166
+ * creates generation 2 (exclusively), and so on. Nothing is ever renamed or overwritten, so two processes that
167
+ * both saw a dead claimer cannot both win: exactly one create succeeds per generation.
168
+ */
169
+ const claimResult = (runKey: string, previousOwner: string, owner: string, mode: "claim" | "peek"): boolean => {
170
+ for (let generation = 1; generation <= MAX_CLAIM_GENERATIONS; generation += 1) {
171
+ const file = claimPath(runKey, generation);
172
+ if (!file) return false;
173
+ let marker = readMarker(file);
174
+ if (marker.state === "missing") {
175
+ if (mode === "peek") return true;
176
+ if (createMarkerExclusively(file, { claimedBy: owner, previousOwner, claimedAt: now() })) return true;
177
+ marker = readMarker(file);
178
+ }
179
+ if (marker.state !== "ok") return false;
180
+ if (marker.claimedBy === owner) return true;
181
+ if (verdictOf(marker.claimedBy) !== "dead") return false;
182
+ }
183
+ return false;
184
+ };
185
+
186
+ const collectGarbage = (): void => {
187
+ const at = now();
188
+ if (at - lastGcAt < GC_INTERVAL_MS) return;
189
+ lastGcAt = at;
190
+ const owner = currentOwner();
191
+ const root = ownersDir();
192
+ let examined = 0;
193
+ let deleted = 0;
194
+ const old = (file: string, ageMs: number): boolean => {
195
+ try {
196
+ return at - fs.statSync(file).mtimeMs > ageMs;
197
+ } catch {
198
+ return false;
199
+ }
200
+ };
201
+ try {
202
+ for (const name of fs.readdirSync(root)) {
203
+ if (examined >= GC_MAX_ENTRIES_EXAMINED || deleted >= GC_MAX_DELETIONS) break;
204
+ if (!name.endsWith(".json")) continue;
205
+ examined += 1;
206
+ const file = path.join(root, name);
207
+ if (!old(file, OWNER_RECORD_GC_AGE_MS)) continue;
208
+ const record = (() => {
209
+ try {
210
+ return parseOwnerRecord(JSON.parse(fs.readFileSync(file, "utf-8")));
211
+ } catch {
212
+ return undefined;
213
+ }
214
+ })();
215
+ if (record?.completionOwnerId === owner) continue;
216
+ if (record && recordVerdict(record.pid, record.startKey, record.hostname) !== "dead") continue;
217
+ fs.rmSync(file, { force: true });
218
+ deleted += 1;
219
+ }
220
+ } catch {
221
+ // Garbage collection is best effort and must never affect delivery.
222
+ }
223
+ try {
224
+ const claims = claimsDir();
225
+ for (const name of fs.readdirSync(claims)) {
226
+ if (examined >= GC_MAX_ENTRIES_EXAMINED || deleted >= GC_MAX_DELETIONS) break;
227
+ examined += 1;
228
+ const file = path.join(claims, name);
229
+ if (name.endsWith(".tmp")) {
230
+ if (old(file, STALE_TEMP_AGE_MS)) {
231
+ fs.rmSync(file, { force: true });
232
+ deleted += 1;
233
+ }
234
+ continue;
235
+ }
236
+ if (!name.endsWith(".json") || !old(file, OWNER_RECORD_GC_AGE_MS)) continue;
237
+ // Only the newest generation of a key may go, or an older dead claim could be re-created beneath a live one.
238
+ const match = /^(.*?)(?:%g(\d+))?\.json$/.exec(name);
239
+ if (!match) continue;
240
+ const generation = match[2] ? Number(match[2]) : 1;
241
+ if (fs.existsSync(path.join(claims, `${match[1]}%g${generation + 1}.json`))) continue;
242
+ if (!old(file, CLAIM_GC_HARD_AGE_MS)) {
243
+ const marker = readMarker(file);
244
+ if (marker.state === "ok" && (marker.claimedBy === owner || verdictOf(marker.claimedBy) !== "dead")) continue;
245
+ }
246
+ fs.rmSync(file, { force: true });
247
+ deleted += 1;
248
+ }
249
+ } catch {
250
+ // Best effort, as above.
251
+ }
252
+ };
253
+
254
+ const takeOver = (sessionId: string, completionOwnerId: unknown, runKey: string | undefined, mode: "claim" | "peek"): boolean => {
255
+ const owner = currentOwner();
256
+ if (!owner || typeof completionOwnerId !== "string" || !completionOwnerId || completionOwnerId === owner) return false;
257
+ if (typeof runKey !== "string" || !runKey || typeof sessionId !== "string" || !sessionId) return false;
258
+ if (!sessionIsOurs(sessionId, owner)) return false;
259
+ const taken = takenOver.get(runKey);
260
+ if (taken && taken.by === owner && taken.sessionId === sessionId && taken.previousOwner === completionOwnerId) return true;
261
+ try {
262
+ collectGarbage();
263
+ if (verdictOf(completionOwnerId) !== "dead") return false;
264
+ if (!claimResult(runKey, completionOwnerId, owner, mode)) return false;
265
+ } catch {
266
+ // Any filesystem surprise means we cannot prove exclusive ownership: fail safe.
267
+ return false;
268
+ }
269
+ if (mode === "claim") {
270
+ takenOver.delete(runKey);
271
+ takenOver.set(runKey, { sessionId, previousOwner: completionOwnerId, by: owner });
272
+ while (takenOver.size > MAX_TAKEN_RESULTS) takenOver.delete(takenOver.keys().next().value!);
273
+ }
274
+ return true;
275
+ };
20
276
 
21
277
  return {
22
- owns(sessionId, completionOwnerId) {
278
+ owns(sessionId, completionOwnerId, runKey) {
23
279
  const owner = currentOwner();
24
- if (!owner || completionOwnerId !== owner) return false;
25
- return sessionId === state.currentSessionId || claimed.get(sessionId) === owner;
280
+ if (!owner) return false;
281
+ if (completionOwnerId === owner) return sessionId === state.currentSessionId || claimed.get(sessionId) === owner;
282
+ return takeOver(sessionId, completionOwnerId, runKey, "claim");
283
+ },
284
+ canTakeOver(sessionId, completionOwnerId, runKey) {
285
+ return takeOver(sessionId, completionOwnerId, runKey, "peek");
26
286
  },
27
287
  claimedSessionIds() {
28
288
  const owner = currentOwner();
@@ -40,6 +300,8 @@ export function createResultDeliveryOwnership(state: ResultDeliveryState): Resul
40
300
  },
41
301
  clear() {
42
302
  claimed.clear();
303
+ takenOver.clear();
304
+ verdicts.clear();
43
305
  },
44
306
  };
45
- }
307
+ }
@@ -223,16 +223,17 @@ export function createResultWatcher(
223
223
  // The sole in-memory ownership lease. It is acquired for one active session
224
224
  // and revoked before the watcher, queues, or callbacks are torn down.
225
225
  let activeSessionId: string | null = null;
226
+ // `runKey` lets the shared ownership take over a result left by a provably dead previous owner.
226
227
  const ownsResult = deps.ownership?.owns
227
- ?? ((sessionId: string, completionOwnerId: unknown) => sessionId === state.currentSessionId
228
+ ?? ((sessionId: string, completionOwnerId: unknown, _runKey?: string) => sessionId === state.currentSessionId
228
229
  && typeof completionOwnerId === "string"
229
230
  && completionOwnerId === state.completionOwnerId);
230
231
  const claimedSessionIds = () => deps.ownership?.claimedSessionIds() ?? [];
231
232
 
232
- const ownsCompletion = (sessionId: string, completionOwnerId: unknown, epoch: number) => {
233
+ const ownsCompletion = (sessionId: string, completionOwnerId: unknown, epoch: number, runKey?: string) => {
233
234
  if (!deliveryActive || epoch !== deliveryEpoch) return false;
234
235
  if (!activeSessionId && state.currentSessionId) activeSessionId = state.currentSessionId;
235
- return activeSessionId === state.currentSessionId && ownsResult(sessionId, completionOwnerId);
236
+ return activeSessionId === state.currentSessionId && ownsResult(sessionId, completionOwnerId, runKey);
236
237
  };
237
238
 
238
239
  const scheduleResult = (file: string, triggerTurn: boolean, delayMs = 0) => {
@@ -318,7 +319,7 @@ export function createResultWatcher(
318
319
  // Missing identity stays on the normal parser path so malformed or legacy
319
320
  // files keep their existing diagnostics and compatibility behavior.
320
321
  if (!identity.sessionId) return true;
321
- if (ownsResult(identity.sessionId, identity.completionOwnerId)) return true;
322
+ if (ownsResult(identity.sessionId, identity.completionOwnerId, identity.runId)) return true;
322
323
  if (identity.asyncDir && fsApi.existsSync(path.join(identity.asyncDir, MISSION_BINDING_FILE))) return true;
323
324
  if (identity.runId && (observed ?? observedRunIds()).has(identity.runId)) return true;
324
325
  return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
@@ -425,7 +426,7 @@ export function createResultWatcher(
425
426
  }
426
427
  if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
427
428
  const epoch = deliveryEpoch;
428
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
429
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
429
430
  // Recorded before dedupe and before the unlink below so bg_wait can
430
431
  // use the in-memory record or its bounded durable replay after cleanup.
431
432
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
@@ -454,7 +455,7 @@ export function createResultWatcher(
454
455
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
455
456
  return;
456
457
  }
457
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
458
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
458
459
  if (markReplacedPayload()) return;
459
460
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
460
461
  return;
@@ -511,7 +512,7 @@ export function createResultWatcher(
511
512
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
512
513
  return;
513
514
  }
514
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
515
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
515
516
  if (markReplacedPayload()) return;
516
517
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
517
518
  return;
@@ -533,7 +534,7 @@ export function createResultWatcher(
533
534
  asyncDir: data.asyncDir,
534
535
  ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
535
536
  }));
536
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
537
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
537
538
  if (!intercomDelivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
538
539
  }
539
540
 
@@ -557,7 +558,7 @@ export function createResultWatcher(
557
558
  })) : [],
558
559
  } : {}),
559
560
  });
560
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
561
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
561
562
  if (!accepted) {
562
563
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
563
564
  return;
@@ -599,7 +600,7 @@ export function createResultWatcher(
599
600
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
600
601
  return;
601
602
  }
602
- if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
603
+ if (!ownsCompletion(sessionId, completionOwnerId, epoch, runId)) return;
603
604
  if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
604
605
  } catch (error) {
605
606
  if (isAccessDenied(error)) {
@@ -13,7 +13,7 @@ const DEFAULT_POLL_INTERVAL_MS = 5000;
13
13
  const REFRESH_DEBOUNCE_MS = 25;
14
14
 
15
15
  export function createRetainedNestedRouteTracker(
16
- state: Pick<SubagentState, "retainedForegroundNestedRoutes">,
16
+ state: Pick<SubagentState, "retainedForegroundNestedRoutes" | "retainedForegroundNestedChildren"> & Partial<Pick<SubagentState, "currentSessionId">>,
17
17
  options: RetainedNestedRouteTrackerOptions = {},
18
18
  ): { track: (rootRunId: string) => void; clear: () => void } {
19
19
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
@@ -32,12 +32,23 @@ export function createRetainedNestedRouteTracker(
32
32
 
33
33
  const refresh = (rootRunId: string): void => {
34
34
  const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
35
- if (!retained) {
35
+ const cached = state.retainedForegroundNestedChildren?.get(rootRunId);
36
+ if (!retained || cached && cached.sessionId !== state.currentSessionId) {
37
+ state.retainedForegroundNestedChildren?.delete(rootRunId);
38
+ state.retainedForegroundNestedRoutes?.delete(rootRunId);
36
39
  close(rootRunId);
37
40
  return;
38
41
  }
39
42
  try {
40
- if (hasLiveNestedDescendants(projectNestedEvents(retained).children)) return;
43
+ const children = projectNestedEvents(retained).children;
44
+ if (hasLiveNestedDescendants(children)) {
45
+ if (state.currentSessionId) {
46
+ state.retainedForegroundNestedChildren ??= new Map();
47
+ state.retainedForegroundNestedChildren.set(rootRunId, { sessionId: state.currentSessionId, ...(cached?.workflowId ? { workflowId: cached.workflowId } : {}), children });
48
+ }
49
+ return;
50
+ }
51
+ state.retainedForegroundNestedChildren?.delete(rootRunId);
41
52
  state.retainedForegroundNestedRoutes?.delete(rootRunId);
42
53
  close(rootRunId);
43
54
  } catch (error) {
@@ -90,6 +101,7 @@ export function createRetainedNestedRouteTracker(
90
101
  for (const timer of refreshTimers.values()) clearTimeout(timer);
91
102
  refreshTimers.clear();
92
103
  state.retainedForegroundNestedRoutes?.clear();
104
+ state.retainedForegroundNestedChildren?.clear();
93
105
  };
94
106
 
95
107
  return { track, clear };
@@ -562,6 +562,70 @@ export class ScheduledRunManager {
562
562
  return new Set(this.observedAsyncIds);
563
563
  }
564
564
 
565
+ /** Read-only: schedules that currently hold an armed timer in THIS process (used by release readiness). */
566
+ armedSchedules(): Array<{ id: string; nextRunAt?: number; sessionOnly?: boolean }> {
567
+ const armed: Array<{ id: string; nextRunAt?: number; sessionOnly?: boolean }> = [];
568
+ for (const key of this.timers.keys()) {
569
+ const [root = "", id = ""] = key.split("\0");
570
+ const entry: { id: string; nextRunAt?: number; sessionOnly?: boolean } = { id };
571
+ try {
572
+ const schedule = this.stores.get(root)?.find(id);
573
+ const next = schedule?.trigger.nextRunAt ? Date.parse(schedule.trigger.nextRunAt) : Number.NaN;
574
+ if (Number.isFinite(next)) entry.nextRunAt = next;
575
+ if (schedule) entry.sessionOnly = schedule.sessionOnly === true;
576
+ } catch {
577
+ // An unreadable schedule is still armed; report it without detail.
578
+ }
579
+ armed.push(entry);
580
+ }
581
+ return armed;
582
+ }
583
+
584
+ /**
585
+ * Kill switch (`stop_all_background`): disarms every armed SESSION-ONLY schedule of this process. Each one is PAUSED (persisted,
586
+ * `schedule.paused` event) and its timer cleared, so it neither fires now nor on the next launch (`restoreOne`/`runDue` skip
587
+ * paused schedules, so `catchUp: "latest"` cannot revive it). Pause is chosen over delete on purpose: it is reversible
588
+ * (`schedule.resume`), keeps the schedule's history and active-run bookkeeping, and `delete` refuses a schedule with an active run.
589
+ * Project schedules are NOT touched (D11: they persist on disk and re-arm from the project on any launch). An armed timer whose
590
+ * record cannot be read is left alone and reported (it may be a project schedule). Idempotent: a disarmed schedule holds no timer.
591
+ */
592
+ disarmSessionOnlySchedules(): Array<{ id: string; ok: boolean; detail: string; error?: string }> {
593
+ const results: Array<{ id: string; ok: boolean; detail: string; error?: string }> = [];
594
+ for (const key of [...this.timers.keys()]) {
595
+ const [root = "", id = ""] = key.split("\0");
596
+ const store = this.stores.get(root);
597
+ try {
598
+ if (!store) throw new Error("schedule store is not loaded");
599
+ const schedule = store.find(id);
600
+ if (!schedule) {
601
+ // Deleted by another process while this one still held the timer: nothing to pause, just un-arm.
602
+ this.clearTimer(store, id);
603
+ results.push({ id, ok: true, detail: `Schedule ${id} no longer exists; cleared its timer` });
604
+ continue;
605
+ }
606
+ if (schedule.sessionOnly !== true) continue;
607
+ try {
608
+ if (!schedule.paused) {
609
+ schedule.paused = true;
610
+ schedule.updatedAt = timestamp(this.now());
611
+ store.write(schedule);
612
+ store.appendEvent(schedule, "schedule.paused");
613
+ }
614
+ } catch (error) {
615
+ results.push({ id, ok: false, detail: `Session-only schedule ${id}: timer cleared but the pause could not be saved, so it may fire on the next launch: ${error instanceof Error ? error.message : String(error)}`, error: error instanceof Error ? error.message : String(error) });
616
+ continue;
617
+ } finally {
618
+ this.clearTimer(store, id);
619
+ }
620
+ results.push({ id, ok: true, detail: `Paused session-only schedule ${id} and cleared its timer (schedule.resume re-arms it)` });
621
+ } catch (error) {
622
+ const text = error instanceof Error ? error.message : String(error);
623
+ results.push({ id, ok: false, detail: `Armed schedule ${id} could not be read (${text}); left armed because it may be a project schedule`, error: text });
624
+ }
625
+ }
626
+ return results;
627
+ }
628
+
565
629
  referencedAsyncRunIds(): Set<string> {
566
630
  const runIds = new Set(this.observedAsyncIds);
567
631
  for (const store of this.stores.values()) {
@@ -79,6 +79,7 @@ import {
79
79
  formatControlIntercomMessage,
80
80
  formatControlNoticeMessage,
81
81
  shouldEmitOpenToolAttention,
82
+ forgetControlNotifications,
82
83
  } from "../shared/subagent-control.ts";
83
84
  import {
84
85
  type RunnerSubagentStep as SubagentStep,
@@ -2735,6 +2736,53 @@ export async function runSubagent(
2735
2736
  currentActivityState = nextRunState;
2736
2737
  setOptionalProperty(statusPayload, "activityState", nextRunState);
2737
2738
  };
2739
+ /**
2740
+ * Why a step is flagged `needs_attention`, by flat index, so the flag is dropped when THAT cause resolves (decision D12).
2741
+ * `supervisor_request` is tracked separately by `supervisorAttentionSteps`. Terminal handling (stop/interrupt/timeout/final
2742
+ * status) still clears the flag unconditionally, and the `partial`-with-evidence and completion-guard paths stay terminal-only.
2743
+ */
2744
+ type AttentionCause = "tool_open_threshold" | "tool_failures" | "idle" | "steering";
2745
+ const attentionCauses = new Map<number, Set<AttentionCause>>();
2746
+ const attentionResumeState = new Map<number, ActivityState | undefined>();
2747
+ const noteAttentionCause = (flatIndex: number, cause: AttentionCause, previous: ActivityState | undefined): void => {
2748
+ const causes = attentionCauses.get(flatIndex) ?? new Set<AttentionCause>();
2749
+ if (causes.size === 0 && previous !== "needs_attention") attentionResumeState.set(flatIndex, previous);
2750
+ causes.add(cause);
2751
+ attentionCauses.set(flatIndex, causes);
2752
+ };
2753
+ const emitAttentionCleared = (flatIndex: number, cause: AttentionCause | "supervisor_request", to: ActivityState | undefined, now: number): void => {
2754
+ const step = statusPayload.steps[flatIndex];
2755
+ appendJsonl(eventsPath, JSON.stringify({
2756
+ type: "subagent.attention.cleared",
2757
+ ts: now,
2758
+ runId: id,
2759
+ ...(step?.agent ? { agent: step.agent } : {}),
2760
+ index: flatIndex,
2761
+ from: "needs_attention",
2762
+ to: to ?? null,
2763
+ reason: cause,
2764
+ }));
2765
+ };
2766
+ /**
2767
+ * Drops one `needs_attention` cause. When it was the last one holding the step, restores the step's previous non-attention state,
2768
+ * recomputes the run-level state, lets the same problem notify again if it recurs and appends `subagent.attention.cleared`.
2769
+ * The caller persists status. Returns true when the step's state changed.
2770
+ */
2771
+ const resolveAttentionCause = (flatIndex: number, cause: AttentionCause, now: number): boolean => {
2772
+ const causes = attentionCauses.get(flatIndex);
2773
+ if (!causes?.delete(cause) || causes.size > 0) return false;
2774
+ attentionCauses.delete(flatIndex);
2775
+ const resumeState = attentionResumeState.get(flatIndex);
2776
+ attentionResumeState.delete(flatIndex);
2777
+ const step = statusPayload.steps[flatIndex];
2778
+ if (!step || step.status !== "running" || step.activityState !== "needs_attention") return false;
2779
+ if (supervisorAttentionSteps.has(flatIndex)) return false;
2780
+ setOptionalProperty(step, "activityState", resumeState);
2781
+ syncAggregateActivityState();
2782
+ if (cause !== "steering") forgetControlNotifications(emittedControlEventKeys, { runId: id, index: flatIndex }, cause, config.childIntercomTargets?.[flatIndex]);
2783
+ emitAttentionCleared(flatIndex, cause, resumeState, now);
2784
+ return true;
2785
+ };
2738
2786
  const maybeEmitOpenToolAttention = (flatIndex: number, now: number): boolean => {
2739
2787
  const step = statusPayload.steps[flatIndex];
2740
2788
  if (!step || step.status !== "running" || step.activityState === "needs_attention") return false;
@@ -2742,6 +2790,7 @@ export async function runSubagent(
2742
2790
  if (!target) return false;
2743
2791
  const previous = step.activityState;
2744
2792
  step.activityState = "needs_attention";
2793
+ noteAttentionCause(flatIndex, "tool_open_threshold", previous);
2745
2794
  statusPayload.activityState = "needs_attention";
2746
2795
  const toolDurationMs = Math.max(0, now - target.startedAt);
2747
2796
  appendControlEvent(buildControlEvent(omitUndefinedProperties({
@@ -2802,7 +2851,10 @@ export async function runSubagent(
2802
2851
  const steeringMarkerPath = (requestId: string): string => path.join(asyncDir, "control", "steer-recovery", `${Buffer.from(requestId).toString("base64url")}.json`);
2803
2852
  const markSteeringAttention = (index: number): void => {
2804
2853
  const step = statusPayload.steps[index];
2805
- if (step) step.activityState = "needs_attention";
2854
+ if (step) {
2855
+ noteAttentionCause(index, "steering", step.activityState);
2856
+ step.activityState = "needs_attention";
2857
+ }
2806
2858
  statusPayload.activityState = "needs_attention";
2807
2859
  };
2808
2860
  const emitSteeringEvent = (type: string, request: SteerRequest, index?: number, extra: Record<string, unknown> = {}): void => {
@@ -2903,6 +2955,8 @@ export async function runSubagent(
2903
2955
  const now = Date.now();
2904
2956
  if (delivery.state === "delivered") {
2905
2957
  updateSteeringLifecycleTarget(requestId, index, late ? "late" : "delivered", now, omitUndefinedProperties({ reason: late ? "acknowledged after recovery commit" : undefined }));
2958
+ // A delivered (or late-acknowledged) steer shows the child accepts steering again: the steering problem is over.
2959
+ resolveAttentionCause(index, "steering", now);
2906
2960
  emitSteeringEvent("subagent.steer.delivered", { type: "steer", id: requestId, ts: now, message: delivery.message }, index, { late, deliveryStatus: "delivered", message: delivery.message });
2907
2961
  } else if (delivery.state === "queued") {
2908
2962
  updateSteeringLifecycleTarget(requestId, index, "queued", now);
@@ -3029,9 +3083,11 @@ export async function runSubagent(
3029
3083
  const supervisorPreviousActivity = supervisorAttentionSteps.get(flatIndex);
3030
3084
  const stillBlockingSupervisor = [...(activeToolCalls[flatIndex]?.values() ?? [])].some((active) => active.blocksSupervisor);
3031
3085
  const clearedSupervisorAttention = endedTool?.blocksSupervisor && !stillBlockingSupervisor ? supervisorAttentionSteps.delete(flatIndex) : false;
3032
- if (clearedSupervisorAttention && step.activityState === "needs_attention") {
3086
+ // Another unresolved cause (e.g. a failed steer) keeps the step flagged; its own resolution restores the state.
3087
+ if (clearedSupervisorAttention && step.activityState === "needs_attention" && !attentionCauses.get(flatIndex)?.size) {
3033
3088
  setOptionalProperty(step, "activityState", supervisorPreviousActivity);
3034
3089
  syncAggregateActivityState();
3090
+ emitAttentionCleared(flatIndex, "supervisor_request", supervisorPreviousActivity, now);
3035
3091
  }
3036
3092
  syncTopLevelCurrentTool();
3037
3093
  } else if (event.type === "tool_result_end" && event.message) {
@@ -3059,6 +3115,7 @@ export async function runSubagent(
3059
3115
  if (controlConfig.enabled && shouldEscalateMutatingFailures(state, controlConfig.failedToolAttemptsBeforeAttention) && step.activityState !== "needs_attention") {
3060
3116
  const previous = step.activityState;
3061
3117
  step.activityState = "needs_attention";
3118
+ noteAttentionCause(flatIndex, "tool_failures", previous);
3062
3119
  statusPayload.activityState = "needs_attention";
3063
3120
  appendControlEvent(buildControlEvent(omitUndefinedProperties({
3064
3121
  type: "needs_attention",
@@ -3081,6 +3138,8 @@ export async function runSubagent(
3081
3138
  }
3082
3139
  } else if (toolSnapshot?.mutates) {
3083
3140
  resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
3141
+ // The failure streak that raised the flag is broken by a successful mutating tool.
3142
+ resolveAttentionCause(flatIndex, "tool_failures", now);
3084
3143
  }
3085
3144
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
3086
3145
  appendRecentStepOutput(step, stripAcceptanceReport(extractTextFromContent(event.message.content)).split("\n").slice(-10));
@@ -3108,6 +3167,8 @@ export async function runSubagent(
3108
3167
  step.lastActivityAt = now;
3109
3168
  statusPayload.lastActivityAt = now;
3110
3169
  statusPayload.lastUpdate = now;
3170
+ // The open tool that raised `tool_open_threshold` ended (or no remaining open tool is past the threshold): the step recovered.
3171
+ if (attentionCauses.get(flatIndex)?.has("tool_open_threshold") && !openToolAttentionTarget(flatIndex, now)) resolveAttentionCause(flatIndex, "tool_open_threshold", now);
3111
3172
  maybeEmitActiveLongRunning(flatIndex, now);
3112
3173
  // A sibling may keep aggregate attention unchanged; publish this step's transition.
3113
3174
  writeStatusPayload(step.activityState !== previousActivityState);
@@ -3137,6 +3198,7 @@ export async function runSubagent(
3137
3198
  if (idleState === "needs_attention") {
3138
3199
  const previous = step.activityState;
3139
3200
  step.activityState = "needs_attention";
3201
+ noteAttentionCause(index, "idle", previous);
3140
3202
  if (previous !== "needs_attention") {
3141
3203
  appendControlEvent(buildControlEvent(omitUndefinedProperties({
3142
3204
  from: previous,
@@ -3149,10 +3211,11 @@ export async function runSubagent(
3149
3211
  })));
3150
3212
  changed = true;
3151
3213
  }
3152
- } else if (maybeEmitOpenToolAttention(index, now)) {
3153
- changed = true;
3154
- } else if (maybeEmitActiveLongRunning(index, now)) {
3155
- changed = true;
3214
+ } else {
3215
+ // New activity (or an open tool) ended the idle period that raised the flag.
3216
+ if (resolveAttentionCause(index, "idle", now)) changed = true;
3217
+ if (maybeEmitOpenToolAttention(index, now)) changed = true;
3218
+ else if (maybeEmitActiveLongRunning(index, now)) changed = true;
3156
3219
  }
3157
3220
  }
3158
3221
  if (statusPayload.lastActivityAt !== runLastActivityAt) {