2026-01-07 06:53:01 +01:00
|
|
|
import { loadConfig } from "../config/config.js";
|
|
|
|
|
import { callGateway } from "../gateway/call.js";
|
|
|
|
|
import { onAgentEvent } from "../infra/agent-events.js";
|
2026-01-17 05:48:34 +00:00
|
|
|
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
|
2026-01-15 10:18:07 +05:30
|
|
|
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
|
2026-01-13 07:37:24 +00:00
|
|
|
import {
|
|
|
|
|
loadSubagentRegistryFromDisk,
|
|
|
|
|
saveSubagentRegistryToDisk,
|
|
|
|
|
} from "./subagent-registry.store.js";
|
2026-01-12 01:58:24 +00:00
|
|
|
import { resolveAgentTimeoutMs } from "./timeout.js";
|
2026-01-07 06:53:01 +01:00
|
|
|
|
|
|
|
|
export type SubagentRunRecord = {
|
|
|
|
|
runId: string;
|
|
|
|
|
childSessionKey: string;
|
|
|
|
|
requesterSessionKey: string;
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin?: DeliveryContext;
|
2026-01-07 06:53:01 +01:00
|
|
|
requesterDisplayKey: string;
|
|
|
|
|
task: string;
|
|
|
|
|
cleanup: "delete" | "keep";
|
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-08 23:17:08 +00:00
|
|
|
label?: string;
|
2026-01-07 06:53:01 +01:00
|
|
|
createdAt: number;
|
|
|
|
|
startedAt?: number;
|
|
|
|
|
endedAt?: number;
|
2026-01-15 10:18:07 +05:30
|
|
|
outcome?: SubagentRunOutcome;
|
2026-01-07 06:53:01 +01:00
|
|
|
archiveAtMs?: number;
|
2026-01-15 23:06:58 +00:00
|
|
|
cleanupCompletedAt?: number;
|
|
|
|
|
cleanupHandled?: boolean;
|
2026-01-07 06:53:01 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const subagentRuns = new Map<string, SubagentRunRecord>();
|
|
|
|
|
let sweeper: NodeJS.Timeout | null = null;
|
|
|
|
|
let listenerStarted = false;
|
2026-01-13 10:10:15 +00:00
|
|
|
let listenerStop: (() => void) | null = null;
|
2026-01-23 00:59:44 +00:00
|
|
|
// Use var to avoid TDZ when init runs across circular imports during bootstrap.
|
|
|
|
|
var restoreAttempted = false;
|
2026-02-07 20:02:32 -08:00
|
|
|
const SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
|
2026-01-13 07:37:24 +00:00
|
|
|
|
|
|
|
|
function persistSubagentRuns() {
|
|
|
|
|
try {
|
|
|
|
|
saveSubagentRegistryToDisk(subagentRuns);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore persistence failures
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resumedRuns = new Set<string>();
|
|
|
|
|
|
|
|
|
|
function resumeSubagentRun(runId: string) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!runId || resumedRuns.has(runId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
const entry = subagentRuns.get(runId);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (entry.cleanupCompletedAt) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
|
|
|
|
|
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!beginSubagentCleanup(runId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-17 03:57:59 +00:00
|
|
|
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
|
2026-01-15 23:06:58 +00:00
|
|
|
void runSubagentAnnounceFlow({
|
2026-01-13 07:37:24 +00:00
|
|
|
childSessionKey: entry.childSessionKey,
|
|
|
|
|
childRunId: entry.runId,
|
|
|
|
|
requesterSessionKey: entry.requesterSessionKey,
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin,
|
2026-01-13 07:37:24 +00:00
|
|
|
requesterDisplayKey: entry.requesterDisplayKey,
|
|
|
|
|
task: entry.task,
|
2026-02-07 20:02:32 -08:00
|
|
|
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
|
2026-01-13 07:37:24 +00:00
|
|
|
cleanup: entry.cleanup,
|
|
|
|
|
waitForCompletion: false,
|
|
|
|
|
startedAt: entry.startedAt,
|
|
|
|
|
endedAt: entry.endedAt,
|
|
|
|
|
label: entry.label,
|
2026-01-15 10:18:07 +05:30
|
|
|
outcome: entry.outcome,
|
2026-01-15 23:06:58 +00:00
|
|
|
}).then((didAnnounce) => {
|
|
|
|
|
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
2026-01-13 10:10:15 +00:00
|
|
|
});
|
2026-01-13 07:37:24 +00:00
|
|
|
resumedRuns.add(runId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for completion again after restart.
|
|
|
|
|
const cfg = loadConfig();
|
|
|
|
|
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, undefined);
|
|
|
|
|
void waitForSubagentCompletion(runId, waitTimeoutMs);
|
|
|
|
|
resumedRuns.add(runId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreSubagentRunsOnce() {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (restoreAttempted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
restoreAttempted = true;
|
|
|
|
|
try {
|
|
|
|
|
const restored = loadSubagentRegistryFromDisk();
|
2026-01-31 16:19:20 +09:00
|
|
|
if (restored.size === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
for (const [runId, entry] of restored.entries()) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!runId || !entry) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
// Keep any newer in-memory entries.
|
|
|
|
|
if (!subagentRuns.has(runId)) {
|
|
|
|
|
subagentRuns.set(runId, entry);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resume pending work.
|
|
|
|
|
ensureListener();
|
|
|
|
|
if ([...subagentRuns.values()].some((entry) => entry.archiveAtMs)) {
|
|
|
|
|
startSweeper();
|
|
|
|
|
}
|
|
|
|
|
for (const runId of subagentRuns.keys()) {
|
|
|
|
|
resumeSubagentRun(runId);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore restore failures
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
|
2026-01-12 01:58:24 +00:00
|
|
|
function resolveArchiveAfterMs(cfg?: ReturnType<typeof loadConfig>) {
|
|
|
|
|
const config = cfg ?? loadConfig();
|
|
|
|
|
const minutes = config.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
return Math.max(1, Math.floor(minutes)) * 60_000;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 01:58:24 +00:00
|
|
|
function resolveSubagentWaitTimeoutMs(
|
|
|
|
|
cfg: ReturnType<typeof loadConfig>,
|
|
|
|
|
runTimeoutSeconds?: number,
|
|
|
|
|
) {
|
|
|
|
|
return resolveAgentTimeoutMs({ cfg, overrideSeconds: runTimeoutSeconds });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 06:53:01 +01:00
|
|
|
function startSweeper() {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (sweeper) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
sweeper = setInterval(() => {
|
|
|
|
|
void sweepSubagentRuns();
|
|
|
|
|
}, 60_000);
|
|
|
|
|
sweeper.unref?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopSweeper() {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!sweeper) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
clearInterval(sweeper);
|
|
|
|
|
sweeper = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sweepSubagentRuns() {
|
|
|
|
|
const now = Date.now();
|
2026-01-13 07:37:24 +00:00
|
|
|
let mutated = false;
|
2026-01-07 06:53:01 +01:00
|
|
|
for (const [runId, entry] of subagentRuns.entries()) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry.archiveAtMs || entry.archiveAtMs > now) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
subagentRuns.delete(runId);
|
2026-01-13 07:37:24 +00:00
|
|
|
mutated = true;
|
2026-01-07 06:53:01 +01:00
|
|
|
try {
|
|
|
|
|
await callGateway({
|
|
|
|
|
method: "sessions.delete",
|
|
|
|
|
params: { key: entry.childSessionKey, deleteTranscript: true },
|
|
|
|
|
timeoutMs: 10_000,
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
if (mutated) {
|
|
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
|
|
|
|
if (subagentRuns.size === 0) {
|
|
|
|
|
stopSweeper();
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureListener() {
|
2026-01-15 23:06:58 +00:00
|
|
|
if (listenerStarted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
listenerStarted = true;
|
2026-01-13 10:10:15 +00:00
|
|
|
listenerStop = onAgentEvent((evt) => {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!evt || evt.stream !== "lifecycle") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
const entry = subagentRuns.get(evt.runId);
|
2026-01-11 06:17:15 +00:00
|
|
|
if (!entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
const phase = evt.data?.phase;
|
|
|
|
|
if (phase === "start") {
|
2026-01-31 16:03:28 +09:00
|
|
|
const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined;
|
2026-01-13 07:37:24 +00:00
|
|
|
if (startedAt) {
|
|
|
|
|
entry.startedAt = startedAt;
|
|
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-31 16:19:20 +09:00
|
|
|
if (phase !== "end" && phase !== "error") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-31 16:03:28 +09:00
|
|
|
const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now();
|
2026-01-07 06:53:01 +01:00
|
|
|
entry.endedAt = endedAt;
|
2026-01-15 10:18:07 +05:30
|
|
|
if (phase === "error") {
|
2026-01-31 16:03:28 +09:00
|
|
|
const error = typeof evt.data?.error === "string" ? evt.data.error : undefined;
|
2026-01-15 10:18:07 +05:30
|
|
|
entry.outcome = { status: "error", error };
|
2026-02-12 01:55:30 +08:00
|
|
|
} else if (evt.data?.aborted) {
|
|
|
|
|
entry.outcome = { status: "timeout" };
|
2026-01-15 10:18:07 +05:30
|
|
|
} else {
|
|
|
|
|
entry.outcome = { status: "ok" };
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
persistSubagentRuns();
|
2026-01-09 14:01:49 +01:00
|
|
|
|
2026-01-15 23:06:58 +00:00
|
|
|
if (!beginSubagentCleanup(evt.runId)) {
|
2026-01-07 06:53:01 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-17 03:57:59 +00:00
|
|
|
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
|
2026-01-15 23:06:58 +00:00
|
|
|
void runSubagentAnnounceFlow({
|
2026-01-07 06:53:01 +01:00
|
|
|
childSessionKey: entry.childSessionKey,
|
|
|
|
|
childRunId: entry.runId,
|
|
|
|
|
requesterSessionKey: entry.requesterSessionKey,
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin,
|
2026-01-07 06:53:01 +01:00
|
|
|
requesterDisplayKey: entry.requesterDisplayKey,
|
|
|
|
|
task: entry.task,
|
2026-02-07 20:02:32 -08:00
|
|
|
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
|
2026-01-07 06:53:01 +01:00
|
|
|
cleanup: entry.cleanup,
|
|
|
|
|
waitForCompletion: false,
|
|
|
|
|
startedAt: entry.startedAt,
|
|
|
|
|
endedAt: entry.endedAt,
|
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-08 23:17:08 +00:00
|
|
|
label: entry.label,
|
2026-01-15 10:18:07 +05:30
|
|
|
outcome: entry.outcome,
|
2026-01-15 23:06:58 +00:00
|
|
|
}).then((didAnnounce) => {
|
|
|
|
|
finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce);
|
2026-01-13 10:10:15 +00:00
|
|
|
});
|
2026-01-07 06:53:01 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 03:24:53 +00:00
|
|
|
function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) {
|
2026-01-13 10:10:15 +00:00
|
|
|
const entry = subagentRuns.get(runId);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-07 20:02:32 -08:00
|
|
|
if (!didAnnounce) {
|
|
|
|
|
// Allow retry on the next wake if announce was deferred or failed.
|
|
|
|
|
entry.cleanupHandled = false;
|
2026-01-13 10:10:15 +00:00
|
|
|
persistSubagentRuns();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-07 20:02:32 -08:00
|
|
|
if (cleanup === "delete") {
|
|
|
|
|
subagentRuns.delete(runId);
|
2026-01-15 23:06:58 +00:00
|
|
|
persistSubagentRuns();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
entry.cleanupCompletedAt = Date.now();
|
2026-01-13 10:10:15 +00:00
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 23:06:58 +00:00
|
|
|
function beginSubagentCleanup(runId: string) {
|
2026-01-07 06:53:01 +01:00
|
|
|
const entry = subagentRuns.get(runId);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (entry.cleanupCompletedAt) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (entry.cleanupHandled) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-15 23:06:58 +00:00
|
|
|
entry.cleanupHandled = true;
|
2026-01-13 07:37:24 +00:00
|
|
|
persistSubagentRuns();
|
2026-01-07 06:53:01 +01:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function registerSubagentRun(params: {
|
|
|
|
|
runId: string;
|
|
|
|
|
childSessionKey: string;
|
|
|
|
|
requesterSessionKey: string;
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin?: DeliveryContext;
|
2026-01-07 06:53:01 +01:00
|
|
|
requesterDisplayKey: string;
|
|
|
|
|
task: string;
|
|
|
|
|
cleanup: "delete" | "keep";
|
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-08 23:17:08 +00:00
|
|
|
label?: string;
|
2026-01-12 01:58:24 +00:00
|
|
|
runTimeoutSeconds?: number;
|
2026-01-07 06:53:01 +01:00
|
|
|
}) {
|
|
|
|
|
const now = Date.now();
|
2026-01-12 01:58:24 +00:00
|
|
|
const cfg = loadConfig();
|
|
|
|
|
const archiveAfterMs = resolveArchiveAfterMs(cfg);
|
2026-01-07 06:53:01 +01:00
|
|
|
const archiveAtMs = archiveAfterMs ? now + archiveAfterMs : undefined;
|
2026-01-14 14:31:43 +00:00
|
|
|
const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, params.runTimeoutSeconds);
|
2026-01-17 03:57:59 +00:00
|
|
|
const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin);
|
2026-01-07 06:53:01 +01:00
|
|
|
subagentRuns.set(params.runId, {
|
|
|
|
|
runId: params.runId,
|
|
|
|
|
childSessionKey: params.childSessionKey,
|
|
|
|
|
requesterSessionKey: params.requesterSessionKey,
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin,
|
2026-01-07 06:53:01 +01:00
|
|
|
requesterDisplayKey: params.requesterDisplayKey,
|
|
|
|
|
task: params.task,
|
|
|
|
|
cleanup: params.cleanup,
|
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-08 23:17:08 +00:00
|
|
|
label: params.label,
|
2026-01-07 06:53:01 +01:00
|
|
|
createdAt: now,
|
|
|
|
|
startedAt: now,
|
|
|
|
|
archiveAtMs,
|
2026-01-15 23:06:58 +00:00
|
|
|
cleanupHandled: false,
|
2026-01-07 06:53:01 +01:00
|
|
|
});
|
|
|
|
|
ensureListener();
|
2026-01-13 07:37:24 +00:00
|
|
|
persistSubagentRuns();
|
2026-01-31 16:19:20 +09:00
|
|
|
if (archiveAfterMs) {
|
|
|
|
|
startSweeper();
|
|
|
|
|
}
|
2026-01-11 06:17:15 +00:00
|
|
|
// Wait for subagent completion via gateway RPC (cross-process).
|
|
|
|
|
// The in-process lifecycle listener is a fallback for embedded runs.
|
2026-01-12 01:58:24 +00:00
|
|
|
void waitForSubagentCompletion(params.runId, waitTimeoutMs);
|
2026-01-07 06:53:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-12 01:58:24 +00:00
|
|
|
async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
2026-01-07 06:53:01 +01:00
|
|
|
try {
|
2026-01-12 01:58:24 +00:00
|
|
|
const timeoutMs = Math.max(1, Math.floor(waitTimeoutMs));
|
2026-01-31 16:48:44 +09:00
|
|
|
const wait = await callGateway<{
|
|
|
|
|
status?: string;
|
|
|
|
|
startedAt?: number;
|
|
|
|
|
endedAt?: number;
|
|
|
|
|
error?: string;
|
|
|
|
|
}>({
|
2026-01-07 06:53:01 +01:00
|
|
|
method: "agent.wait",
|
|
|
|
|
params: {
|
|
|
|
|
runId,
|
2026-01-12 01:58:24 +00:00
|
|
|
timeoutMs,
|
2026-01-07 06:53:01 +01:00
|
|
|
},
|
2026-01-12 01:58:24 +00:00
|
|
|
timeoutMs: timeoutMs + 10_000,
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-02-12 01:55:30 +08:00
|
|
|
if (wait?.status !== "ok" && wait?.status !== "error" && wait?.status !== "timeout") {
|
2026-01-31 16:19:20 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
const entry = subagentRuns.get(runId);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
let mutated = false;
|
|
|
|
|
if (typeof wait.startedAt === "number") {
|
|
|
|
|
entry.startedAt = wait.startedAt;
|
|
|
|
|
mutated = true;
|
|
|
|
|
}
|
|
|
|
|
if (typeof wait.endedAt === "number") {
|
|
|
|
|
entry.endedAt = wait.endedAt;
|
|
|
|
|
mutated = true;
|
|
|
|
|
}
|
|
|
|
|
if (!entry.endedAt) {
|
|
|
|
|
entry.endedAt = Date.now();
|
|
|
|
|
mutated = true;
|
|
|
|
|
}
|
2026-01-31 07:51:26 +00:00
|
|
|
const waitError = typeof wait.error === "string" ? wait.error : undefined;
|
2026-01-15 10:18:07 +05:30
|
|
|
entry.outcome =
|
2026-02-12 01:55:30 +08:00
|
|
|
wait.status === "error"
|
|
|
|
|
? { status: "error", error: waitError }
|
|
|
|
|
: wait.status === "timeout"
|
|
|
|
|
? { status: "timeout" }
|
|
|
|
|
: { status: "ok" };
|
2026-01-15 10:18:07 +05:30
|
|
|
mutated = true;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (mutated) {
|
|
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
|
|
|
|
if (!beginSubagentCleanup(runId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-17 03:57:59 +00:00
|
|
|
const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin);
|
2026-01-15 23:06:58 +00:00
|
|
|
void runSubagentAnnounceFlow({
|
2026-01-07 06:53:01 +01:00
|
|
|
childSessionKey: entry.childSessionKey,
|
|
|
|
|
childRunId: entry.runId,
|
|
|
|
|
requesterSessionKey: entry.requesterSessionKey,
|
2026-01-17 03:57:59 +00:00
|
|
|
requesterOrigin,
|
2026-01-07 06:53:01 +01:00
|
|
|
requesterDisplayKey: entry.requesterDisplayKey,
|
|
|
|
|
task: entry.task,
|
2026-02-07 20:02:32 -08:00
|
|
|
timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS,
|
2026-01-07 06:53:01 +01:00
|
|
|
cleanup: entry.cleanup,
|
|
|
|
|
waitForCompletion: false,
|
|
|
|
|
startedAt: entry.startedAt,
|
|
|
|
|
endedAt: entry.endedAt,
|
feat(sessions): expose label in sessions.list and support label lookup in sessions_send
- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
2026-01-08 23:17:08 +00:00
|
|
|
label: entry.label,
|
2026-01-15 10:18:07 +05:30
|
|
|
outcome: entry.outcome,
|
2026-01-15 23:06:58 +00:00
|
|
|
}).then((didAnnounce) => {
|
|
|
|
|
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
2026-01-13 10:10:15 +00:00
|
|
|
});
|
2026-01-07 06:53:01 +01:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resetSubagentRegistryForTests() {
|
|
|
|
|
subagentRuns.clear();
|
2026-01-13 10:10:15 +00:00
|
|
|
resumedRuns.clear();
|
2026-01-07 06:53:01 +01:00
|
|
|
stopSweeper();
|
2026-01-13 07:37:24 +00:00
|
|
|
restoreAttempted = false;
|
2026-01-13 10:10:15 +00:00
|
|
|
if (listenerStop) {
|
|
|
|
|
listenerStop();
|
|
|
|
|
listenerStop = null;
|
|
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
listenerStarted = false;
|
|
|
|
|
persistSubagentRuns();
|
2026-01-07 06:53:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-18 04:44:52 +00:00
|
|
|
export function addSubagentRunForTests(entry: SubagentRunRecord) {
|
|
|
|
|
subagentRuns.set(entry.runId, entry);
|
|
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 06:53:01 +01:00
|
|
|
export function releaseSubagentRun(runId: string) {
|
2026-01-13 07:37:24 +00:00
|
|
|
const didDelete = subagentRuns.delete(runId);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (didDelete) {
|
|
|
|
|
persistSubagentRuns();
|
|
|
|
|
}
|
|
|
|
|
if (subagentRuns.size === 0) {
|
|
|
|
|
stopSweeper();
|
|
|
|
|
}
|
2026-01-07 06:53:01 +01:00
|
|
|
}
|
2026-01-13 07:37:24 +00:00
|
|
|
|
2026-01-16 21:37:11 +00:00
|
|
|
export function listSubagentRunsForRequester(requesterSessionKey: string): SubagentRunRecord[] {
|
|
|
|
|
const key = requesterSessionKey.trim();
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!key) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-01-16 21:37:11 +00:00
|
|
|
return [...subagentRuns.values()].filter((entry) => entry.requesterSessionKey === key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 10:10:15 +00:00
|
|
|
export function initSubagentRegistry() {
|
|
|
|
|
restoreSubagentRunsOnce();
|
|
|
|
|
}
|