@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
@@ -187,8 +187,21 @@ export function shouldNotifyControlEvent(config: ResolvedControlConfig, event: C
187
187
  return config.enabled && config.notifyOn.includes(event.type);
188
188
  }
189
189
 
190
+ function controlNotificationChildKey(event: Pick<ControlEvent, "runId" | "index">, childIntercomTarget?: string): string {
191
+ return childIntercomTarget ?? (event.index !== undefined ? `${event.runId}:${event.index}` : event.runId);
192
+ }
193
+
194
+ /**
195
+ * Forget the de-duplication keys of one child's `needs_attention` notifications for `reason`, so the same problem can notify again
196
+ * after it was cleared and then recurs.
197
+ */
198
+ export function forgetControlNotifications(seenKeys: Set<string>, target: Pick<ControlEvent, "runId" | "index">, reason: NonNullable<ControlEvent["reason"]>, childIntercomTarget?: string): void {
199
+ const prefix = `${controlNotificationChildKey(target, childIntercomTarget)}:needs_attention:${reason}:`;
200
+ for (const key of [...seenKeys]) if (key.startsWith(prefix)) seenKeys.delete(key);
201
+ }
202
+
190
203
  export function controlNotificationKey(event: ControlEvent, childIntercomTarget?: string): string {
191
- const childKey = childIntercomTarget ?? (event.index !== undefined ? `${event.runId}:${event.index}` : event.runId);
204
+ const childKey = controlNotificationChildKey(event, childIntercomTarget);
192
205
  const contextHash = createHash("sha256").update(formatControlNudge(event)).digest("hex").slice(0, 8);
193
206
  return `${childKey}:${event.type}:${event.reason ?? "idle"}:${contextHash}`;
194
207
  }
@@ -0,0 +1,107 @@
1
+ import { createHash } 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 { writePrivateAtomicJson } from "./atomic-json.ts";
6
+ import { currentCompletionOwnerId } from "./completion-owner.ts";
7
+ import { processStartKey } from "./process-identity.ts";
8
+
9
+ /**
10
+ * On-disk identity of one parent process, keyed by its `completionOwnerId`. Detached background runners outlive
11
+ * their parent, so a later process needs this record to prove the owner of a finished result is really gone.
12
+ * Records are deliberately NOT removed on shutdown: a cleanly exited parent is exactly the case where its
13
+ * runners finish later and a resumed process must take the results over.
14
+ */
15
+ export interface OwnerRecord {
16
+ version: 1;
17
+ completionOwnerId: string;
18
+ pid: number;
19
+ startKey?: string;
20
+ hostname: string;
21
+ createdAt: number;
22
+ }
23
+
24
+ const MAX_ENCODED_SEGMENT_LENGTH = 200;
25
+
26
+ /** Collision-free, traversal-safe file stem for an id that may come from an untrusted result file. */
27
+ export function ownerFileStem(id: string): string | undefined {
28
+ if (typeof id !== "string" || !id) return undefined;
29
+ const encoded = encodeURIComponent(id);
30
+ if (encoded.length <= MAX_ENCODED_SEGMENT_LENGTH) return encoded;
31
+ return `h-${createHash("sha256").update(id).digest("hex")}`;
32
+ }
33
+
34
+ export function ownerRecordPath(ownersDir: string, completionOwnerId: string): string | undefined {
35
+ const stem = ownerFileStem(completionOwnerId);
36
+ return stem ? path.join(ownersDir, `${stem}.json`) : undefined;
37
+ }
38
+
39
+ export function parseOwnerRecord(value: unknown): OwnerRecord | undefined {
40
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
41
+ const record = value as Record<string, unknown>;
42
+ if (record.version !== 1) return undefined;
43
+ if (typeof record.completionOwnerId !== "string" || !record.completionOwnerId) return undefined;
44
+ if (!Number.isSafeInteger(record.pid) || (record.pid as number) <= 0) return undefined;
45
+ if (typeof record.hostname !== "string" || !record.hostname) return undefined;
46
+ if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)) return undefined;
47
+ return {
48
+ version: 1,
49
+ completionOwnerId: record.completionOwnerId,
50
+ pid: record.pid as number,
51
+ ...(typeof record.startKey === "string" && record.startKey ? { startKey: record.startKey } : {}),
52
+ hostname: record.hostname,
53
+ createdAt: record.createdAt,
54
+ };
55
+ }
56
+
57
+ /** Returns undefined for a missing, unreadable, or malformed record; callers must treat that as "unknown". */
58
+ export function readOwnerRecord(ownersDir: string, completionOwnerId: string): OwnerRecord | undefined {
59
+ const file = ownerRecordPath(ownersDir, completionOwnerId);
60
+ if (!file) return undefined;
61
+ try {
62
+ const record = parseOwnerRecord(JSON.parse(fs.readFileSync(file, "utf-8")));
63
+ return record?.completionOwnerId === completionOwnerId ? record : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ export interface EnsureOwnerRecordOptions {
70
+ ownersDir: string;
71
+ completionOwnerId?: string;
72
+ pid?: number;
73
+ hostname?: string;
74
+ startKey?: () => string | undefined;
75
+ now?: () => number;
76
+ }
77
+
78
+ /**
79
+ * Writes this process's owner record unless an identical one is already on disk (an extension reload in the same
80
+ * process shares the owner id and must not rewrite it). Returns true when a file was written.
81
+ */
82
+ export function ensureOwnerRecord(options: EnsureOwnerRecordOptions): boolean {
83
+ const completionOwnerId = options.completionOwnerId ?? currentCompletionOwnerId();
84
+ const pid = options.pid ?? process.pid;
85
+ const hostname = options.hostname ?? os.hostname();
86
+ const file = ownerRecordPath(options.ownersDir, completionOwnerId);
87
+ if (!file) return false;
88
+ const existing = readOwnerRecord(options.ownersDir, completionOwnerId);
89
+ if (existing && existing.pid === pid && existing.hostname === hostname) return false;
90
+ const startKey = (options.startKey ?? (() => processStartKey(pid)))();
91
+ const record: OwnerRecord = {
92
+ version: 1,
93
+ completionOwnerId,
94
+ pid,
95
+ ...(startKey ? { startKey } : {}),
96
+ hostname,
97
+ createdAt: (options.now ?? Date.now)(),
98
+ };
99
+ fs.mkdirSync(options.ownersDir, { recursive: true, mode: 0o700 });
100
+ try {
101
+ fs.chmodSync(options.ownersDir, 0o700);
102
+ } catch {
103
+ // Best effort (no-op on Windows); the records themselves are written 0600.
104
+ }
105
+ writePrivateAtomicJson(file, record);
106
+ return true;
107
+ }
@@ -0,0 +1,87 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+
4
+ export type ProcessLiveness = "alive" | "dead" | "unknown";
5
+
6
+ type KillFn = (pid: number, signal: 0) => unknown;
7
+
8
+ /**
9
+ * `kill(pid, 0)` probe. ESRCH proves the pid is gone, EPERM proves it exists (owned by someone else),
10
+ * anything else proves nothing. A live pid may still be a *reused* pid; pair with `processStartKey`.
11
+ */
12
+ export function processLiveness(pid: number, kill: KillFn = process.kill): ProcessLiveness {
13
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "unknown";
14
+ try {
15
+ kill(pid, 0);
16
+ return "alive";
17
+ } catch (error) {
18
+ const code = typeof error === "object" && error !== null && "code" in error
19
+ ? (error as NodeJS.ErrnoException).code
20
+ : undefined;
21
+ if (code === "ESRCH") return "dead";
22
+ if (code === "EPERM") return "alive";
23
+ return "unknown";
24
+ }
25
+ }
26
+
27
+ export function linuxProcessStartKey(pid: number): string | undefined {
28
+ try {
29
+ const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
30
+ const tail = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
31
+ return tail[19] ? `linux:${tail[19]}` : undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export function psProcessStartKey(pid: number): string | undefined {
38
+ try {
39
+ const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
40
+ return raw ? `ps:${raw}` : undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ export function windowsProcessStartKey(pid: number): string | undefined {
47
+ try {
48
+ const raw = execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\").CreationDate`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000, windowsHide: true }).trim();
49
+ return raw ? `win:${raw}` : undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ type StartKeyResolvers = {
56
+ linux?: (pid: number) => string | undefined;
57
+ ps?: (pid: number) => string | undefined;
58
+ windows?: (pid: number) => string | undefined;
59
+ };
60
+
61
+ /**
62
+ * Resolves a start key without caching. Linux prefers `/proc` and falls back to `ps`; Windows uses CIM; every
63
+ * other unix (macOS, BSDs) uses `ps -o lstart=`. `undefined` means the identity could not be established.
64
+ */
65
+ export function computeProcessStartKey(pid: number, platform: NodeJS.Platform = process.platform, resolvers: StartKeyResolvers = {}): string | undefined {
66
+ const linux = resolvers.linux ?? linuxProcessStartKey;
67
+ const ps = resolvers.ps ?? psProcessStartKey;
68
+ const windows = resolvers.windows ?? windowsProcessStartKey;
69
+ if (platform === "linux") return linux(pid) ?? ps(pid);
70
+ if (platform === "win32") return windows(pid);
71
+ return ps(pid);
72
+ }
73
+
74
+ let currentProcessKey: string | undefined | null = null;
75
+
76
+ export function processStartKey(pid: number): string | undefined {
77
+ // Foreign PIDs can be reused while this process lives, so resolve them afresh.
78
+ if (pid !== process.pid) return computeProcessStartKey(pid);
79
+ if (currentProcessKey === null) currentProcessKey = computeProcessStartKey(pid);
80
+ return currentProcessKey;
81
+ }
82
+
83
+ /** Start keys are only comparable within one scheme (`linux:`, `ps:`, `win:`). */
84
+ export function startKeyScheme(key: string): string {
85
+ const index = key.indexOf(":");
86
+ return index < 0 ? "" : key.slice(0, index);
87
+ }
@@ -1986,6 +1986,10 @@ export type AsyncJobStep = Omit<NonNullable<AsyncStatus["steps"]>[number], "time
1986
1986
  };
1987
1987
 
1988
1988
  export interface AsyncJobState {
1989
+ /** Native lifecycle evidence retained for read-only host observation. */
1990
+ endedAt?: number;
1991
+ error?: string;
1992
+ processTerminal?: ProcessTerminal;
1989
1993
  asyncId: string;
1990
1994
  asyncDir: string;
1991
1995
  /** Host tool-call id retained when it differs from the internal run id. */
@@ -2247,6 +2251,8 @@ export interface SubagentState {
2247
2251
  liveAsyncSessionRoots?: Map<string, string>;
2248
2252
  /** Foreground nested routes retained after their direct parent settles, keyed by root run id. */
2249
2253
  retainedForegroundNestedRoutes?: Map<string, NestedRouteInfo>;
2254
+ /** Observer cache seeded/refreshed only by the existing retained-route tracker. */
2255
+ retainedForegroundNestedChildren?: Map<string, { sessionId: string; workflowId?: string; children: NestedRunSummary[] }>;
2250
2256
  /** Last valid parent session model observed for this session; used when continuation contexts omit ctx.model. */
2251
2257
  lastParentModel?: { provider: string; id: string };
2252
2258
  subagentInProgress?: boolean;
@@ -2755,9 +2761,12 @@ export const RESULTS_DIR = path.join(TEMP_ROOT_DIR, "async-subagent-results");
2755
2761
  export const ASYNC_DIR = path.join(TEMP_ROOT_DIR, "async-subagent-runs");
2756
2762
  export const CHAIN_RUNS_DIR = path.join(TEMP_ROOT_DIR, "chain-runs");
2757
2763
  export const TEMP_ARTIFACTS_DIR = path.join(TEMP_ROOT_DIR, "artifacts");
2764
+ /** Per-user process-owner records + result takeover claims; a sibling of the results dir, outside any project. */
2765
+ export const OWNERS_DIR = path.join(TEMP_ROOT_DIR, "async-subagent-owners");
2758
2766
 
2759
2767
  export const DIRS = {
2760
2768
  results: RESULTS_DIR,
2769
+ owners: OWNERS_DIR,
2761
2770
  async: ASYNC_DIR,
2762
2771
  chain: CHAIN_RUNS_DIR,
2763
2772
  artifacts: TEMP_ARTIFACTS_DIR,
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Integration tests for decision D12: a background step's `needs_attention` flag clears when its cause resolves
3
+ * (the open tool ends, a failed steer is delivered, the failure streak is broken, activity resumes) and can be raised again,
4
+ * while stop/interrupt/terminal still clear it unconditionally.
5
+ *
6
+ * Requires pi packages to be importable. Skips gracefully if unavailable.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { events, makeAgent } from "../support/helpers.ts";
14
+ import { deliverStopRequest, requestAsyncSteer } from "../../src/runs/background/control-channel.ts";
15
+ import type { AsyncStatusPayload } from "../support/async-execution-fixture.ts";
16
+ import {
17
+ installAsyncExecutionHooks, available, isAsyncAvailable, executeAsyncSingle,
18
+ ASYNC_DIR, waitForAsyncResultFile, waitForAsyncState, tempDir, mockPi,
19
+ } from "../support/async-execution-fixture.ts";
20
+
21
+ const skip = !isAsyncAvailable() ? "jiti not available" : undefined;
22
+ const flagged = (status: AsyncStatusPayload) => status.steps?.[0]?.activityState === "needs_attention" && status.activityState === "needs_attention";
23
+ const unflagged = (status: AsyncStatusPayload) => status.steps?.[0]?.activityState !== "needs_attention" && status.activityState !== "needs_attention";
24
+
25
+ type RecordedEvent = { type?: string; reason?: string; from?: string; to?: string | null; index?: number; event?: { type?: string; reason?: string } };
26
+ function readEvents(id: string): RecordedEvent[] {
27
+ const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl");
28
+ if (!fs.existsSync(eventsPath)) return [];
29
+ return fs.readFileSync(eventsPath, "utf-8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as RecordedEvent);
30
+ }
31
+ const clearedEvents = (id: string, reason: string) => readEvents(id).filter((event) => event.type === "subagent.attention.cleared" && event.reason === reason);
32
+ const attentionEvents = (id: string, reason: string) => readEvents(id).filter((event) => event.type === "subagent.control" && event.event?.type === "needs_attention" && event.event.reason === reason);
33
+
34
+ /** An assistant turn that ends in a tool call, so the child keeps running (a `stop` turn would settle it). */
35
+ function midTurnMessage(text: string): object {
36
+ const message = events.assistantMessage(text) as { message: { stopReason: string } };
37
+ message.message.stopReason = "toolUse";
38
+ return message;
39
+ }
40
+
41
+ function launch(id: string, controlConfig: Record<string, unknown>) {
42
+ return executeAsyncSingle(id, {
43
+ agent: "worker",
44
+ task: "Do the work",
45
+ agentConfig: makeAgent("worker"),
46
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
47
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
48
+ shareEnabled: false,
49
+ sessionRoot: path.join(tempDir, "sessions"),
50
+ maxSubagentDepth: 2,
51
+ controlConfig: {
52
+ enabled: true,
53
+ needsAttentionAfterMs: 999_999,
54
+ activeNoticeAfterMs: 100,
55
+ failedToolAttemptsBeforeAttention: 3,
56
+ notifyOn: ["needs_attention"],
57
+ notifyChannels: ["event", "async", "intercom"],
58
+ ...controlConfig,
59
+ },
60
+ });
61
+ }
62
+
63
+ describe("async needs_attention clearing (D12)", { skip: !available ? "pi packages not available" : undefined }, () => {
64
+ installAsyncExecutionHooks();
65
+
66
+ it("clears when the open tool ends and flags again for a second long tool", { timeout: 45_000, skip }, async () => {
67
+ const id = `async-attention-tool-${Date.now().toString(36)}`;
68
+ const release = [1, 2, 3].map((n) => path.join(tempDir, `${id}.${n}`));
69
+ const release3 = release[2]!;
70
+ mockPi.onCall({
71
+ steps: [
72
+ { jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "sleep 600" } }] },
73
+ { waitForPath: release[0]!, jsonl: [{ type: "tool_execution_end", toolCallId: "bash-1", toolName: "bash" }, events.toolResult("bash", "first done")] },
74
+ { waitForPath: release[1]!, jsonl: [{ type: "tool_execution_start", toolCallId: "bash-2", toolName: "bash", args: { command: "sleep 600" } }] },
75
+ { waitForPath: release3, jsonl: [{ type: "tool_execution_end", toolCallId: "bash-2", toolName: "bash" }, events.toolResult("bash", "second done"), events.assistantMessage("Done")] },
76
+ ],
77
+ });
78
+ launch(id, {});
79
+
80
+ await waitForAsyncState(id, flagged);
81
+ assert.equal(attentionEvents(id, "tool_open_threshold").length, 1);
82
+ assert.equal(clearedEvents(id, "tool_open_threshold").length, 0, "still flagged while the tool is open");
83
+
84
+ fs.writeFileSync(release[0]!, "");
85
+ const recovered = await waitForAsyncState(id, unflagged);
86
+ assert.notEqual(recovered.steps?.[0]?.activityState, "needs_attention");
87
+ assert.notEqual(recovered.activityState, "needs_attention");
88
+ const cleared = clearedEvents(id, "tool_open_threshold");
89
+ assert.equal(cleared.length, 1);
90
+ assert.equal(cleared[0]?.from, "needs_attention");
91
+ assert.equal(cleared[0]?.index, 0);
92
+
93
+ fs.writeFileSync(release[1]!, "");
94
+ await waitForAsyncState(id, flagged);
95
+ assert.equal(attentionEvents(id, "tool_open_threshold").length, 2, "a recurrence is flagged and notified again");
96
+
97
+ fs.writeFileSync(release3, "");
98
+ await waitForAsyncResultFile(id);
99
+ assert.equal(clearedEvents(id, "tool_open_threshold").length, 2);
100
+ const finalStatus = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
101
+ assert.equal(finalStatus.activityState, undefined);
102
+ assert.notEqual(finalStatus.steps?.[0]?.activityState, "needs_attention");
103
+ });
104
+
105
+ it("still clears unconditionally on stop while the flag is raised", { timeout: 30_000, skip }, async () => {
106
+ const id = `async-attention-stop-${Date.now().toString(36)}`;
107
+ mockPi.onCall({ steps: [{ jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "sleep 600" } }] }, { waitForPath: path.join(tempDir, `${id}.never`), jsonl: [] }] });
108
+ launch(id, {});
109
+ await waitForAsyncState(id, flagged);
110
+ deliverStopRequest({ asyncDir: path.join(ASYNC_DIR, id), source: "test" });
111
+ const stopped = await waitForAsyncState(id, (status) => status.state === "stopped");
112
+ assert.equal(stopped.activityState, undefined);
113
+ assert.equal(stopped.steps?.[0]?.activityState, undefined);
114
+ assert.equal(clearedEvents(id, "tool_open_threshold").length, 0, "terminal handling does not announce a recovery");
115
+ });
116
+
117
+ it("clears steering attention once a steer is delivered, but not when one is merely queued", { timeout: 30_000, skip }, async () => {
118
+ const id = `async-attention-steer-${Date.now().toString(36)}`;
119
+ const release = path.join(tempDir, `${id}.release`);
120
+ mockPi.onCall({ failSteers: 1, steps: [{ waitForPath: release, jsonl: [events.assistantMessage("Done")] }] });
121
+ launch(id, {});
122
+ const asyncDir = path.join(ASYNC_DIR, id);
123
+ await waitForAsyncState(id, (status) => status.steps?.[0]?.status === "running");
124
+
125
+ requestAsyncSteer(asyncDir, { message: "Please focus on the tests.", targetIndex: 0, source: "test" });
126
+ await waitForAsyncState(id, flagged);
127
+ assert.equal(readEvents(id).filter((event) => event.type === "subagent.steer.failed").length, 1);
128
+
129
+ requestAsyncSteer(asyncDir, { message: "Queue this for later.", mode: "follow_up", targetIndex: 0, source: "test" });
130
+ await waitForAsyncState(id, () => readEvents(id).some((event) => event.type === "subagent.steer.queued"));
131
+ assert.equal(clearedEvents(id, "steering").length, 0, "a queued follow-up is not a delivery");
132
+ const stillFlagged = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
133
+ assert.equal(stillFlagged.steps?.[0]?.activityState, "needs_attention");
134
+
135
+ requestAsyncSteer(asyncDir, { message: "Please focus on the tests, again.", targetIndex: 0, source: "test" });
136
+ await waitForAsyncState(id, unflagged);
137
+ const cleared = clearedEvents(id, "steering");
138
+ assert.equal(cleared.length, 1);
139
+ assert.equal(cleared[0]?.index, 0);
140
+
141
+ fs.writeFileSync(release, "");
142
+ await waitForAsyncResultFile(id);
143
+ });
144
+
145
+ it("clears a mutating-failure flag when a mutating tool succeeds again", { timeout: 30_000, skip }, async () => {
146
+ const id = `async-attention-failures-${Date.now().toString(36)}`;
147
+ const release = path.join(tempDir, `${id}.release`);
148
+ const failingEdit = (n: number) => [
149
+ { type: "tool_execution_start", toolCallId: `edit-${n}`, toolName: "edit", args: { path: "src/a.ts" } },
150
+ { type: "tool_execution_end", toolCallId: `edit-${n}`, toolName: "edit" },
151
+ events.toolResult("edit", "Error: could not find the text to replace", true),
152
+ ];
153
+ mockPi.onCall({
154
+ steps: [
155
+ { jsonl: [...failingEdit(1), ...failingEdit(2), ...failingEdit(3)] },
156
+ { waitForPath: release, jsonl: [
157
+ { type: "tool_execution_start", toolCallId: "edit-4", toolName: "edit", args: { path: "src/a.ts" } },
158
+ { type: "tool_execution_end", toolCallId: "edit-4", toolName: "edit" },
159
+ events.toolResult("edit", "Successfully replaced text in src/a.ts"),
160
+ ] },
161
+ { jsonl: [events.assistantMessage("Done")] },
162
+ ],
163
+ });
164
+ launch(id, { activeNoticeAfterMs: 999_999 });
165
+ await waitForAsyncState(id, flagged);
166
+ assert.equal(attentionEvents(id, "tool_failures").length, 1);
167
+ fs.writeFileSync(release, "");
168
+ await waitForAsyncState(id, unflagged);
169
+ assert.equal(clearedEvents(id, "tool_failures").length, 1);
170
+ await waitForAsyncResultFile(id);
171
+ });
172
+
173
+ it("clears idle attention when the child becomes active again", { timeout: 45_000, skip }, async () => {
174
+ const id = `async-attention-idle-${Date.now().toString(36)}`;
175
+ const release = path.join(tempDir, `${id}.release`);
176
+ mockPi.onCall({
177
+ steps: [
178
+ { jsonl: [midTurnMessage("Thinking about it")] },
179
+ { waitForPath: release, jsonl: [{ type: "tool_execution_start", toolCallId: "read-1", toolName: "read", args: { path: "README.md" } }, { type: "tool_execution_end", toolCallId: "read-1", toolName: "read" }] },
180
+ { delay: 3_500, jsonl: [events.assistantMessage("Done")] },
181
+ ],
182
+ });
183
+ launch(id, { needsAttentionAfterMs: 2_000, activeNoticeAfterMs: 999_999 });
184
+ await waitForAsyncState(id, flagged, 20_000);
185
+ assert.equal(attentionEvents(id, "idle").length, 1);
186
+ fs.writeFileSync(release, "");
187
+ await waitForAsyncState(id, unflagged, 20_000);
188
+ assert.equal(clearedEvents(id, "idle").length, 1);
189
+ await waitForAsyncResultFile(id, 30_000);
190
+ });
191
+ });
@@ -983,6 +983,69 @@ describe("result watcher", () => {
983
983
  }
984
984
  });
985
985
 
986
+ it("takes over a dead previous owner's result after resume, exactly once, and leaves live and foreign-session results alone", async () => {
987
+ const resultsDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-result-watcher-takeover-"));
988
+ const ownersDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-result-watcher-owners-"));
989
+ try {
990
+ const record = (id: string, pid: number) => fs.writeFileSync(path.join(ownersDir, `${id}.json`), JSON.stringify({ version: 1, completionOwnerId: id, pid, hostname: "host-a", createdAt: 1 }));
991
+ record("owner-dead", 100);
992
+ record("owner-live", 200);
993
+ record("resumed-a", 301);
994
+ record("resumed-b", 302);
995
+ const liveness = (pid: number) => (pid === 100 ? "dead" as const : "alive" as const);
996
+ const takeoverDeps = { ownersDir, hostname: () => "host-a", processLiveness: liveness, processStartKey: () => undefined };
997
+ const resumed = (ownerId: string) => {
998
+ const state = createState();
999
+ state.currentSessionId = "resumed-session";
1000
+ state.completionOwnerId = ownerId;
1001
+ const delivered: string[] = [];
1002
+ const events: string[] = [];
1003
+ const watcher = createResultWatcher({ events: { on: () => () => {}, emit(name: string) { events.push(name); } } }, state, resultsDir, 60_000, {
1004
+ deliverIntercomResults: false,
1005
+ ownership: createResultDeliveryOwnership(state, takeoverDeps),
1006
+ notifier: { async deliver(result) { delivered.push(result.id!); return true; } },
1007
+ });
1008
+ return { watcher, delivered, events };
1009
+ };
1010
+ const write = (id: string, owner: string, session = "resumed-session") => writeAsyncResultFile(path.join(resultsDir, `${id}.json`), { id, runId: id, sessionId: session, completionOwnerId: owner, success: true, summary: id });
1011
+ write("orphan", "owner-dead");
1012
+ write("busy", "owner-live");
1013
+ write("no-record", "owner-ghost");
1014
+ write("other-session", "owner-dead", "someone-elses-session");
1015
+
1016
+ // Two resumed processes race to prime the same session: exactly one delivers.
1017
+ const a = resumed("resumed-a");
1018
+ const b = resumed("resumed-b");
1019
+ try {
1020
+ a.watcher.startResultWatcher();
1021
+ b.watcher.startResultWatcher();
1022
+ a.watcher.primeExistingResults();
1023
+ b.watcher.primeExistingResults();
1024
+ assert.equal(await waitForPredicate(() => !fs.existsSync(path.join(resultsDir, "orphan.json"))), true);
1025
+ await new Promise((resolve) => setTimeout(resolve, 100));
1026
+ assert.equal(a.delivered.length + b.delivered.length, 1, "delivered exactly once across both processes");
1027
+ assert.deepEqual([...a.delivered, ...b.delivered], ["orphan"]);
1028
+ assert.equal(fs.existsSync(path.join(resultsDir, "busy.json")), true, "live owner keeps its result");
1029
+ assert.equal(fs.existsSync(path.join(resultsDir, "no-record.json")), true, "missing owner record means no takeover");
1030
+ assert.equal(fs.existsSync(path.join(resultsDir, "other-session.json")), true, "other sessions are never taken");
1031
+ assert.equal(fs.existsSync(path.join(ownersDir, "claims", "orphan.json")), true);
1032
+
1033
+ // A result that appears later (the runner finished after the parent died) is picked up by the next scan.
1034
+ const winner = a.delivered.length ? a : b;
1035
+ write("late", "owner-dead");
1036
+ winner.watcher.primeExistingResults();
1037
+ assert.equal(await waitForPredicate(() => winner.delivered.includes("late")), true);
1038
+ assert.equal(await waitForPredicate(() => !fs.existsSync(path.join(resultsDir, "late.json"))), true);
1039
+ } finally {
1040
+ a.watcher.stopResultWatcher();
1041
+ b.watcher.stopResultWatcher();
1042
+ }
1043
+ } finally {
1044
+ fs.rmSync(resultsDir, { recursive: true, force: true });
1045
+ fs.rmSync(ownersDir, { recursive: true, force: true });
1046
+ }
1047
+ });
1048
+
986
1049
  it("logs malformed result files instead of swallowing them silently", async () => {
987
1050
  const resultsDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-result-watcher-"));
988
1051
  try {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Integration tests for the `stop_all_background` kill switch against REAL detached runner processes (mock pi child):
3
+ * the stop request is only a file in the run's control inbox, the runner consumes it by itself, ends with status `stopped`
4
+ * and exits. Runs of other sessions are untouched.
5
+ *
6
+ * Requires pi packages to be importable. Skips gracefully if unavailable.
7
+ */
8
+
9
+ import { describe, it } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { createEventBus } from "../../../../core/event-bus.ts";
14
+ import { RpcReleaseBridge } from "../../../../modes/rpc/release-bridge.ts";
15
+ import { registerSubagentRpcBridge } from "../../src/extension/rpc.ts";
16
+ import { registerReleaseReadiness } from "../../src/extension/release-readiness.ts";
17
+ import { makeAgent, events } from "../support/helpers.ts";
18
+ import type { AsyncJobState, SubagentState } from "../../src/shared/types.ts";
19
+ import type { ExtensionContext } from "@selesai/code";
20
+ import {
21
+ installAsyncExecutionHooks, available, isAsyncAvailable, executeAsyncSingle,
22
+ ASYNC_DIR, RESULTS_DIR, waitForAsyncResultFile, waitForAsyncState, tempDir, mockPi,
23
+ } from "../support/async-execution-fixture.ts";
24
+
25
+ const skip = !isAsyncAvailable() ? "jiti not available" : undefined;
26
+ const SESSION = "session-1";
27
+
28
+ function launch(id: string, sessionId: string) {
29
+ const receipt = executeAsyncSingle(id, {
30
+ agent: "worker",
31
+ task: "Do the work",
32
+ agentConfig: makeAgent("worker"),
33
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: sessionId },
34
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
35
+ shareEnabled: false,
36
+ sessionRoot: path.join(tempDir, "sessions"),
37
+ maxSubagentDepth: 2,
38
+ });
39
+ assert.equal(receipt.isError === true, false, "launch must succeed");
40
+ }
41
+ const readRunStatus = (id: string) => JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as { state: string; pid?: number };
42
+ const job = (id: string, sessionId: string, pid?: number): AsyncJobState => ({ asyncId: id, asyncDir: path.join(ASYNC_DIR, id), status: "running", startedAt: Date.now(), updatedAt: Date.now(), sessionId, pid } as AsyncJobState);
43
+ const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
44
+ async function waitFor(check: () => boolean, ms: number, what: string): Promise<void> {
45
+ const until = Date.now() + ms;
46
+ while (!check()) {
47
+ if (Date.now() > until) assert.fail(`timed out waiting for ${what}`);
48
+ await new Promise((resolve) => setTimeout(resolve, 25));
49
+ }
50
+ }
51
+
52
+ describe("stop_all_background against real detached runners", { skip: !available ? "pi packages not available" : undefined }, () => {
53
+ installAsyncExecutionHooks();
54
+
55
+ it("stops this session's runner (status stopped, process gone) and leaves another session's runner running", { timeout: 60_000, skip }, async () => {
56
+ const mine = `stopall-mine-${Date.now().toString(36)}`;
57
+ const theirs = `stopall-theirs-${Date.now().toString(36)}`;
58
+ const releaseTheirs = path.join(tempDir, `${theirs}.release`);
59
+ const bash = (n: number) => ({ type: "tool_execution_start", toolCallId: `bash-${n}`, toolName: "bash", args: { command: "sleep 600" } });
60
+ mockPi.onCall({ steps: [{ jsonl: [bash(1)] }, { waitForPath: path.join(tempDir, `${mine}.never`), jsonl: [] }] });
61
+ launch(mine, SESSION);
62
+ await waitForAsyncState(mine, (status) => status.state === "running" && status.steps?.[0]?.status === "running");
63
+ mockPi.onCall({ steps: [{ waitForPath: releaseTheirs, jsonl: [events.assistantMessage("Done")] }] });
64
+ launch(theirs, "session-2");
65
+ await waitForAsyncState(theirs, (status) => status.state === "running" && status.steps?.[0]?.status === "running");
66
+ const minePid = readRunStatus(mine).pid!;
67
+ const theirsPid = readRunStatus(theirs).pid!;
68
+ assert.ok(alive(minePid) && alive(theirsPid));
69
+
70
+ // Session-1's extension state tracks its own run, and (adversarially) also lists session-2's run.
71
+ const state = { baseCwd: tempDir, currentSessionId: SESSION, supervisorOwnerSessionId: "host", asyncJobs: new Map([[mine, job(mine, SESSION, minePid)], [theirs, job(theirs, "session-2", theirsPid)]]), foregroundControls: new Map(), lastForegroundControlId: null } as unknown as SubagentState;
72
+ const bus = createEventBus();
73
+ const ctx = { sessionManager: { getSessionFile: () => SESSION, getSessionId: () => SESSION } } as unknown as ExtensionContext;
74
+ const rpc = registerSubagentRpcBridge({ events: bus, getContext: () => ctx, execute: async () => { throw new Error("unused"); }, asyncDirRoot: ASYNC_DIR, resultsDir: RESULTS_DIR, state });
75
+ registerReleaseReadiness({
76
+ events: bus,
77
+ getHostSessionId: () => "host",
78
+ subagents: { state, hasPendingDelivery: () => false, listUndeliveredResults: () => [], resultsDir: RESULTS_DIR },
79
+ scheduler: { armedSchedules: () => [], observedCompletionRunIds: () => [] },
80
+ stopAll: {
81
+ subagents: { state, resultsDir: RESULTS_DIR, asyncDirRoot: ASYNC_DIR, observedCompletionRunIds: () => [], stopRun: (target) => rpc.stopRun(target) },
82
+ scheduler: { disarmSessionSchedules: () => [] },
83
+ },
84
+ });
85
+
86
+ const bridge = new RpcReleaseBridge(bus, { version: 1, sessionId: "host", epoch: "e1" });
87
+ const reply = await bridge.stopAll({ type: "stop_all_background", version: 1 }, () => [], () => []);
88
+ assert.ok(reply.success && reply.command === "stop_all_background", JSON.stringify(reply));
89
+ assert.deepEqual(reply.data.stopped.map((item) => [item.source, item.kind, item.id, item.ok, item.error]), [
90
+ ["subagents", "async_run", theirs, false, "foreign_session"],
91
+ ["subagents", "async_run", mine, true, undefined],
92
+ ]);
93
+
94
+ // The runner ended with status stopped (written by the detached runner itself) ...
95
+ assert.equal(readRunStatus(mine).state, "stopped");
96
+ const result = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(mine), "utf-8")) as { state?: string; success?: boolean; stopped?: boolean };
97
+ assert.equal(result.state, "stopped");
98
+ assert.equal(result.success, false);
99
+ // ... and the process is gone, not merely idle.
100
+ await waitFor(() => !alive(minePid), 15_000, "the stopped runner process to exit");
101
+ // The other session's runner is untouched and still completes normally.
102
+ assert.equal(readRunStatus(theirs).state, "running");
103
+ assert.ok(alive(theirsPid));
104
+ assert.equal(reply.data.remaining.blockers.some((b) => b.id === mine && b.kind === "async_run"), false, "the stopped run is no longer an async_run blocker");
105
+
106
+ // Idempotent: the run is terminal now, nothing is stopped again (the foreign one is reported again, never touched).
107
+ const again = await bridge.stopAll({ type: "stop_all_background", version: 1 }, () => [], () => []);
108
+ assert.ok(again.success && again.command === "stop_all_background");
109
+ assert.deepEqual(again.data.stopped.map((item) => [item.id, item.ok]), [[theirs, false]]);
110
+
111
+ fs.writeFileSync(releaseTheirs, "");
112
+ const done = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(theirs), "utf-8")) as { state?: string; success?: boolean };
113
+ assert.equal(done.success, true);
114
+ });
115
+
116
+ it("stops a run that is not tracked in memory but awaited by a scheduled run (scheduled_run_pending)", { timeout: 60_000, skip }, async () => {
117
+ const id = `stopall-sched-${Date.now().toString(36)}`;
118
+ mockPi.onCall({ steps: [{ jsonl: [{ type: "tool_execution_start", toolCallId: "bash-1", toolName: "bash", args: { command: "sleep 600" } }] }, { waitForPath: path.join(tempDir, `${id}.never`), jsonl: [] }] });
119
+ launch(id, SESSION);
120
+ await waitForAsyncState(id, (status) => status.state === "running" && status.steps?.[0]?.status === "running");
121
+ const pid = readRunStatus(id).pid!;
122
+ const state = { baseCwd: tempDir, currentSessionId: SESSION, supervisorOwnerSessionId: "host", asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null } as unknown as SubagentState;
123
+ const bus = createEventBus();
124
+ const ctx = { sessionManager: { getSessionFile: () => SESSION, getSessionId: () => SESSION } } as unknown as ExtensionContext;
125
+ const rpc = registerSubagentRpcBridge({ events: bus, getContext: () => ctx, execute: async () => { throw new Error("unused"); }, asyncDirRoot: ASYNC_DIR, resultsDir: RESULTS_DIR, state });
126
+ registerReleaseReadiness({
127
+ events: bus,
128
+ getHostSessionId: () => "host",
129
+ subagents: { state, hasPendingDelivery: () => false, listUndeliveredResults: () => [], resultsDir: RESULTS_DIR },
130
+ scheduler: { armedSchedules: () => [], observedCompletionRunIds: () => [id] },
131
+ stopAll: {
132
+ subagents: { state, resultsDir: RESULTS_DIR, asyncDirRoot: ASYNC_DIR, observedCompletionRunIds: () => [id], stopRun: (target) => rpc.stopRun(target) },
133
+ scheduler: { disarmSessionSchedules: () => [] },
134
+ },
135
+ });
136
+ const reply = await new RpcReleaseBridge(bus, { version: 1, sessionId: "host", epoch: "e1" }).stopAll({ type: "stop_all_background", version: 1 }, () => [], () => []);
137
+ assert.ok(reply.success && reply.command === "stop_all_background", JSON.stringify(reply));
138
+ assert.deepEqual(reply.data.stopped.map((item) => [item.kind, item.id, item.ok]), [["scheduled_run", id, true]]);
139
+ assert.equal(readRunStatus(id).state, "stopped");
140
+ await waitFor(() => !alive(pid), 15_000, "the stopped runner process to exit");
141
+ });
142
+ });