@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.
- package/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +4 -2
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -1,28 +1,288 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
3153
|
-
|
|
3154
|
-
|
|
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) {
|