@selesai/code 0.3.0 → 0.3.1
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/diagnostics.d.ts +1 -0
- package/dist/core/diagnostics.d.ts.map +1 -1
- package/dist/core/diagnostics.js.map +1 -1
- package/dist/core/package-manager.d.ts +10 -0
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +93 -8
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/package-manager.test.d.ts +2 -0
- package/dist/core/package-manager.test.d.ts.map +1 -0
- package/dist/core/package-manager.test.js +20 -0
- package/dist/core/package-manager.test.js.map +1 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +10 -0
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/extensions/caveman/caveman-instructions.cjs +39 -0
- package/dist/extensions/caveman/index.js +116 -0
- package/dist/extensions/caveman/package.json +8 -0
- package/dist/extensions/caveman/test/extension.test.js +185 -0
- package/dist/extensions/caveman/test/helpers.test.js +67 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +2 -3
- package/dist/extensions/pi-intercom/broker/paths.ts +10 -5
- package/dist/extensions/pi-intercom/broker/spawn.ts +2 -3
- package/dist/extensions/pi-intercom/config.ts +2 -2
- package/dist/extensions/pi-intercom/index.ts +2 -2
- package/dist/extensions/pi-intercom/package.json +2 -2
- package/dist/extensions/pi-intercom/ui/compose.ts +3 -3
- package/dist/extensions/pi-intercom/ui/inline-message.ts +3 -3
- package/dist/extensions/pi-intercom/ui/session-list.ts +3 -3
- package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +2 -1
- package/dist/extensions/pi-powerline-footer/index.ts +9 -16
- package/dist/extensions/pi-powerline-footer/welcome.ts +23 -16
- package/dist/extensions/pi-powerline-footer/working-vibes.ts +2 -9
- package/dist/extensions/pi-rewind-hook/index.ts +1 -1
- package/dist/extensions/pi-rewind-hook/package.json +1 -1
- package/dist/extensions/pi-subagents/README.md +224 -52
- package/dist/extensions/pi-subagents/install.mjs +93 -0
- package/dist/extensions/pi-subagents/package.json +4 -5
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +76 -13
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +179 -2
- package/dist/extensions/pi-subagents/src/agents/agent-memory.ts +254 -0
- package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +11 -0
- package/dist/extensions/pi-subagents/src/agents/agents.ts +193 -19
- package/dist/extensions/pi-subagents/src/agents/chain-serializer.ts +27 -2
- package/dist/extensions/pi-subagents/src/extension/config.ts +27 -4
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +1 -7
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -2
- package/dist/extensions/pi-subagents/src/extension/index.ts +73 -71
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +369 -0
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +54 -10
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +200 -0
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +21 -253
- package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +519 -0
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +195 -105
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +88 -2
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +67 -10
- package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +34 -4
- package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +166 -0
- package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +156 -1
- package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +515 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +161 -44
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +1 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +3 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +167 -6
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +514 -0
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +28 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +873 -204
- package/dist/extensions/pi-subagents/src/runs/background/wait.ts +353 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +123 -27
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +174 -70
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +569 -81
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +45 -22
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +3 -1
- package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +172 -21
- package/dist/extensions/pi-subagents/src/runs/shared/model-scope.ts +128 -0
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +89 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +50 -1
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +35 -4
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +63 -45
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +125 -5
- package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +74 -0
- package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +52 -0
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +28 -5
- package/dist/extensions/pi-subagents/src/shared/artifacts.ts +16 -1
- package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +15 -2
- package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +212 -0
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +133 -22
- package/dist/extensions/pi-subagents/src/shared/settings.ts +3 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +197 -4
- package/dist/extensions/pi-subagents/src/shared/utils.ts +100 -15
- package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +330 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +135 -4
- package/dist/extensions/pi-subagents/src/tui/render.ts +32 -12
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +2772 -0
- package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +1107 -0
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +463 -0
- package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +274 -0
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +1535 -0
- package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +242 -0
- package/dist/extensions/pi-subagents/test/integration/doctor-executor.test.ts +128 -0
- package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +252 -0
- package/dist/extensions/pi-subagents/test/integration/foreground-result-size.test.ts +188 -0
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +1306 -0
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +1033 -0
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +397 -0
- package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +583 -0
- package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +691 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +892 -0
- package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1824 -0
- package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1284 -0
- package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +127 -0
- package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +299 -0
- package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +37 -0
- package/dist/extensions/pi-subagents/test/support/helpers.ts +189 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +335 -0
- package/dist/extensions/pi-subagents/test/support/mock-pi.ts +129 -0
- package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +184 -0
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +285 -0
- package/dist/extensions/pi-subagents/test/support/register-loader.mjs +15 -0
- package/dist/extensions/pi-subagents/test/support/ts-loader.mjs +108 -0
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +421 -0
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +343 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +1199 -0
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +556 -0
- package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +420 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +579 -0
- package/dist/extensions/pi-subagents/test/unit/agent-scope.test.ts +20 -0
- package/dist/extensions/pi-subagents/test/unit/agent-selection.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +24 -0
- package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +86 -0
- package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +221 -0
- package/dist/extensions/pi-subagents/test/unit/async-permission-session.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +285 -0
- package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +112 -0
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +206 -0
- package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +159 -0
- package/dist/extensions/pi-subagents/test/unit/chain-serializer.test.ts +229 -0
- package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +225 -0
- package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +101 -0
- package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +228 -0
- package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +152 -0
- package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +72 -0
- package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +327 -0
- package/dist/extensions/pi-subagents/test/unit/control-notices.test.ts +161 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +159 -0
- package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +217 -0
- package/dist/extensions/pi-subagents/test/unit/extra-agent-dirs.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/file-coalescer.test.ts +68 -0
- package/dist/extensions/pi-subagents/test/unit/foreground-tool-call-compaction.test.ts +78 -0
- package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +415 -0
- package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +126 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +271 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +173 -0
- package/dist/extensions/pi-subagents/test/unit/jsonl-writer.test.ts +119 -0
- package/dist/extensions/pi-subagents/test/unit/live-async-resume.test.ts +170 -0
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +346 -0
- package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +62 -0
- package/dist/extensions/pi-subagents/test/unit/model-scope-settings.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +145 -0
- package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +162 -0
- package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +470 -0
- package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +329 -0
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +319 -0
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +70 -0
- package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +247 -0
- package/dist/extensions/pi-subagents/test/unit/path-handling.test.ts +113 -0
- package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +94 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +803 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +238 -0
- package/dist/extensions/pi-subagents/test/unit/proactive-skills.test.ts +118 -0
- package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +299 -0
- package/dist/extensions/pi-subagents/test/unit/prompt-template-bridge.test.ts +408 -0
- package/dist/extensions/pi-subagents/test/unit/prompt-workflows.test.ts +134 -0
- package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +257 -0
- package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +209 -0
- package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +226 -0
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +294 -0
- package/dist/extensions/pi-subagents/test/unit/run-id-resolver.test.ts +147 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +997 -0
- package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +478 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +562 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +438 -0
- package/dist/extensions/pi-subagents/test/unit/slash-bridge.test.ts +54 -0
- package/dist/extensions/pi-subagents/test/unit/slash-chain-groups.test.ts +464 -0
- package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +255 -0
- package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +19 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +245 -0
- package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +517 -0
- package/dist/extensions/pi-subagents/test/unit/temp-paths.test.ts +72 -0
- package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +52 -0
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +237 -0
- package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +60 -0
- package/dist/extensions/pi-subagents/test/unit/ts-loader.test.ts +33 -0
- package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +202 -0
- package/dist/extensions/pi-subagents/test/unit/types-fork-preamble.test.ts +23 -0
- package/dist/extensions/pi-subagents/test/unit/wait.test.ts +398 -0
- package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +88 -0
- package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +26 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-graph.test.ts +134 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +479 -0
- package/dist/extensions/pi-web-agent/src/changelog-notice.ts +5 -2
- package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +6 -4
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/first-time-setup.d.ts +1 -1
- package/dist/modes/interactive/components/first-time-setup.d.ts.map +1 -1
- package/dist/modes/interactive/components/first-time-setup.js +19 -5
- package/dist/modes/interactive/components/first-time-setup.js.map +1 -1
- package/dist/skills/caveman/SKILL.md +49 -0
- package/dist/skills/handoff-text/SKILL.md +13 -0
- package/docs/docs.json +4 -0
- package/docs/extensions.md +2 -0
- package/docs/settings.md +15 -0
- package/docs/shared-host-extensions.md +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { createTempDir, removeTempDir, tryImport } from "../support/helpers.ts";
|
|
6
|
+
|
|
7
|
+
interface AsyncJobTrackerModule {
|
|
8
|
+
createAsyncJobTracker(
|
|
9
|
+
pi: { events: { emit(channel: string, data: unknown): void } },
|
|
10
|
+
state: Record<string, unknown>,
|
|
11
|
+
asyncDirRoot: string,
|
|
12
|
+
options?: {
|
|
13
|
+
completionRetentionMs?: number;
|
|
14
|
+
pollIntervalMs?: number;
|
|
15
|
+
resultsDir?: string;
|
|
16
|
+
kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
},
|
|
19
|
+
): {
|
|
20
|
+
ensurePoller(): void;
|
|
21
|
+
resetJobs(ctx?: unknown): void;
|
|
22
|
+
restoreActiveJobs(ctx?: unknown): void;
|
|
23
|
+
handleStarted(data: unknown): void;
|
|
24
|
+
handleComplete(data: unknown): void;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trackerMod = await tryImport<AsyncJobTrackerModule>("./src/runs/background/async-job-tracker.ts");
|
|
29
|
+
const available = !!trackerMod;
|
|
30
|
+
|
|
31
|
+
function createState() {
|
|
32
|
+
return {
|
|
33
|
+
baseCwd: "/repo",
|
|
34
|
+
currentSessionId: null,
|
|
35
|
+
asyncJobs: new Map(),
|
|
36
|
+
cleanupTimers: new Map(),
|
|
37
|
+
lastUiContext: null,
|
|
38
|
+
poller: null,
|
|
39
|
+
completionSeen: new Map(),
|
|
40
|
+
watcher: null,
|
|
41
|
+
watcherRestartTimer: null,
|
|
42
|
+
resultFileCoalescer: {
|
|
43
|
+
schedule: () => false,
|
|
44
|
+
clear: () => {},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createEventRecorder() {
|
|
50
|
+
const events: Array<{ channel: string; data: unknown }> = [];
|
|
51
|
+
return {
|
|
52
|
+
pi: {
|
|
53
|
+
events: {
|
|
54
|
+
emit: (channel: string, data: unknown) => {
|
|
55
|
+
events.push({ channel, data });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
events,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pidGone(): never {
|
|
64
|
+
const error = new Error("missing") as NodeJS.ErrnoException;
|
|
65
|
+
error.code = "ESRCH";
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function waitForCondition(
|
|
70
|
+
condition: () => boolean,
|
|
71
|
+
description: string,
|
|
72
|
+
timeoutMs = 1000,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
while (!condition()) {
|
|
76
|
+
if (Date.now() > deadline) assert.fail(`Timed out waiting for ${description}`);
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createUiContext() {
|
|
82
|
+
const widgets: unknown[] = [];
|
|
83
|
+
let renderRequests = 0;
|
|
84
|
+
const ctx = {
|
|
85
|
+
hasUI: true,
|
|
86
|
+
ui: {
|
|
87
|
+
theme: {
|
|
88
|
+
fg: (_theme: string, text: string) => text,
|
|
89
|
+
},
|
|
90
|
+
setWidget: (_key: string, value: unknown) => {
|
|
91
|
+
widgets.push(value);
|
|
92
|
+
},
|
|
93
|
+
requestRender: () => {
|
|
94
|
+
renderRequests += 1;
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
ctx,
|
|
100
|
+
get widgets() {
|
|
101
|
+
return widgets;
|
|
102
|
+
},
|
|
103
|
+
get renderRequests() {
|
|
104
|
+
return renderRequests;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("async job tracker", { skip: !available ? "pi packages not available" : undefined }, () => {
|
|
110
|
+
it("removes completed jobs after retention and requests a rerender", async () => {
|
|
111
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
112
|
+
try {
|
|
113
|
+
const state = createState();
|
|
114
|
+
const ui = createUiContext();
|
|
115
|
+
const recorder = createEventRecorder();
|
|
116
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
117
|
+
completionRetentionMs: 5,
|
|
118
|
+
});
|
|
119
|
+
tracker.resetJobs(ui.ctx as never);
|
|
120
|
+
tracker.handleStarted({ id: "run-1", asyncDir: path.join(asyncRoot, "run-1"), agent: "worker" });
|
|
121
|
+
tracker.handleComplete({ id: "run-1", success: true });
|
|
122
|
+
|
|
123
|
+
assert.equal(state.asyncJobs.size, 1);
|
|
124
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
125
|
+
|
|
126
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
127
|
+
assert.ok(ui.renderRequests > 0, "expected widget cleanup to request a rerender");
|
|
128
|
+
assert.equal(ui.widgets.at(-1), undefined);
|
|
129
|
+
} finally {
|
|
130
|
+
removeTempDir(asyncRoot);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("restores active async runs into the widget after reset", async () => {
|
|
135
|
+
const asyncRoot = createTempDir("pi-async-job-restore-");
|
|
136
|
+
try {
|
|
137
|
+
const runDir = path.join(asyncRoot, "run-restored");
|
|
138
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
139
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
140
|
+
runId: "run-restored",
|
|
141
|
+
mode: "chain",
|
|
142
|
+
state: "running",
|
|
143
|
+
sessionId: "session-restored",
|
|
144
|
+
startedAt: 1000,
|
|
145
|
+
lastUpdate: 2000,
|
|
146
|
+
currentStep: 1,
|
|
147
|
+
chainStepCount: 3,
|
|
148
|
+
parallelGroups: [{ start: 1, count: 2, stepIndex: 1 }],
|
|
149
|
+
steps: [
|
|
150
|
+
{ agent: "scout", status: "complete" },
|
|
151
|
+
{ agent: "reviewer", status: "running", currentTool: "read" },
|
|
152
|
+
{ agent: "worker", status: "running" },
|
|
153
|
+
{ agent: "writer", status: "pending" },
|
|
154
|
+
],
|
|
155
|
+
}), "utf-8");
|
|
156
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${JSON.stringify({
|
|
157
|
+
type: "subagent.control",
|
|
158
|
+
channels: ["event"],
|
|
159
|
+
event: {
|
|
160
|
+
type: "needs_attention",
|
|
161
|
+
to: "needs_attention",
|
|
162
|
+
ts: 123,
|
|
163
|
+
runId: "run-restored",
|
|
164
|
+
agent: "reviewer",
|
|
165
|
+
message: "old notice",
|
|
166
|
+
},
|
|
167
|
+
})}\n`, "utf-8");
|
|
168
|
+
|
|
169
|
+
const state = createState();
|
|
170
|
+
state.currentSessionId = "session-restored";
|
|
171
|
+
const ui = createUiContext();
|
|
172
|
+
const recorder = createEventRecorder();
|
|
173
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
174
|
+
pollIntervalMs: 10,
|
|
175
|
+
});
|
|
176
|
+
tracker.resetJobs(ui.ctx as never);
|
|
177
|
+
tracker.restoreActiveJobs(ui.ctx as never);
|
|
178
|
+
|
|
179
|
+
const job = state.asyncJobs.get("run-restored");
|
|
180
|
+
assert.ok(job);
|
|
181
|
+
assert.equal(job.status, "running");
|
|
182
|
+
assert.equal(job.sessionId, "session-restored");
|
|
183
|
+
assert.deepEqual(job.agents, ["reviewer", "worker"]);
|
|
184
|
+
assert.deepEqual(job.steps?.map((step: { index?: number }) => step.index), [1, 2]);
|
|
185
|
+
assert.equal(job.stepsTotal, 2);
|
|
186
|
+
assert.equal(job.runningSteps, 2);
|
|
187
|
+
assert.equal(job.completedSteps, 0);
|
|
188
|
+
assert.equal(job.activeParallelGroup, true);
|
|
189
|
+
assert.ok(state.poller, "expected restored active jobs to start polling");
|
|
190
|
+
assert.ok(ui.renderRequests >= 2, "expected reset and restore to request widget renders");
|
|
191
|
+
assert.equal(typeof ui.widgets.at(-1), "function", "expected restored jobs to render the widget");
|
|
192
|
+
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
194
|
+
assert.equal(recorder.events.length, 0, "historical control events should not be replayed during restore");
|
|
195
|
+
} finally {
|
|
196
|
+
removeTempDir(asyncRoot);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("restores only active async runs for the current session", () => {
|
|
201
|
+
const asyncRoot = createTempDir("pi-async-job-restore-scope-");
|
|
202
|
+
try {
|
|
203
|
+
const ownerDir = path.join(asyncRoot, "run-owner");
|
|
204
|
+
const otherDir = path.join(asyncRoot, "run-other");
|
|
205
|
+
fs.mkdirSync(ownerDir, { recursive: true });
|
|
206
|
+
fs.mkdirSync(otherDir, { recursive: true });
|
|
207
|
+
fs.writeFileSync(path.join(ownerDir, "status.json"), JSON.stringify({
|
|
208
|
+
runId: "run-owner",
|
|
209
|
+
mode: "single",
|
|
210
|
+
state: "running",
|
|
211
|
+
sessionId: "session-owner",
|
|
212
|
+
startedAt: 1000,
|
|
213
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
214
|
+
}), "utf-8");
|
|
215
|
+
fs.writeFileSync(path.join(otherDir, "status.json"), JSON.stringify({
|
|
216
|
+
runId: "run-other",
|
|
217
|
+
mode: "single",
|
|
218
|
+
state: "running",
|
|
219
|
+
sessionId: "session-other",
|
|
220
|
+
startedAt: 1000,
|
|
221
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
222
|
+
}), "utf-8");
|
|
223
|
+
|
|
224
|
+
const state = createState();
|
|
225
|
+
state.currentSessionId = "session-owner";
|
|
226
|
+
const tracker = trackerMod!.createAsyncJobTracker(createEventRecorder().pi, state as never, asyncRoot, {
|
|
227
|
+
pollIntervalMs: 10,
|
|
228
|
+
});
|
|
229
|
+
tracker.restoreActiveJobs();
|
|
230
|
+
|
|
231
|
+
assert.deepEqual([...state.asyncJobs.keys()], ["run-owner"]);
|
|
232
|
+
tracker.resetJobs();
|
|
233
|
+
} finally {
|
|
234
|
+
removeTempDir(asyncRoot);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("ignores started and complete events without the current session id", async () => {
|
|
239
|
+
const asyncRoot = createTempDir("pi-async-job-event-scope-");
|
|
240
|
+
try {
|
|
241
|
+
const state = createState();
|
|
242
|
+
state.currentSessionId = "session-owner";
|
|
243
|
+
const tracker = trackerMod!.createAsyncJobTracker(createEventRecorder().pi, state as never, asyncRoot, {
|
|
244
|
+
completionRetentionMs: 5,
|
|
245
|
+
pollIntervalMs: 10,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
tracker.handleStarted({ id: "run-sessionless", asyncDir: path.join(asyncRoot, "run-sessionless"), agent: "worker" });
|
|
249
|
+
tracker.handleStarted({ id: "run-other", asyncDir: path.join(asyncRoot, "run-other"), agent: "worker", sessionId: "session-other" });
|
|
250
|
+
tracker.handleStarted({ id: "run-owner", asyncDir: path.join(asyncRoot, "run-owner"), agent: "worker", sessionId: "session-owner" });
|
|
251
|
+
|
|
252
|
+
assert.deepEqual([...state.asyncJobs.keys()], ["run-owner"]);
|
|
253
|
+
|
|
254
|
+
tracker.handleComplete({ id: "run-owner", success: true });
|
|
255
|
+
tracker.handleComplete({ id: "run-owner", success: true, sessionId: "session-other" });
|
|
256
|
+
assert.equal(state.asyncJobs.get("run-owner")?.status, "queued");
|
|
257
|
+
|
|
258
|
+
tracker.handleComplete({ id: "run-owner", success: true, sessionId: "session-owner" });
|
|
259
|
+
await waitForCondition(() => !state.asyncJobs.has("run-owner"), "owned job cleanup after matching completion", 1000);
|
|
260
|
+
} finally {
|
|
261
|
+
removeTempDir(asyncRoot);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not throw during restore when a persisted async status is malformed", () => {
|
|
266
|
+
const asyncRoot = createTempDir("pi-async-job-restore-bad-status-");
|
|
267
|
+
const originalError = console.error;
|
|
268
|
+
try {
|
|
269
|
+
const runDir = path.join(asyncRoot, "run-bad-status");
|
|
270
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
271
|
+
fs.writeFileSync(path.join(runDir, "status.json"), "{bad json", "utf-8");
|
|
272
|
+
|
|
273
|
+
const state = createState();
|
|
274
|
+
state.currentSessionId = "session-bad";
|
|
275
|
+
const ui = createUiContext();
|
|
276
|
+
const recorder = createEventRecorder();
|
|
277
|
+
const errors: unknown[][] = [];
|
|
278
|
+
console.error = (...args: unknown[]) => {
|
|
279
|
+
errors.push(args);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
283
|
+
pollIntervalMs: 10,
|
|
284
|
+
});
|
|
285
|
+
tracker.resetJobs(ui.ctx as never);
|
|
286
|
+
assert.doesNotThrow(() => tracker.restoreActiveJobs(ui.ctx as never));
|
|
287
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
288
|
+
assert.equal(state.poller, null);
|
|
289
|
+
assert.match(String(errors[0]?.[0] ?? ""), /Failed to restore active async jobs/);
|
|
290
|
+
} finally {
|
|
291
|
+
console.error = originalError;
|
|
292
|
+
removeTempDir(asyncRoot);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("uses flattened async-start agents for initial parallel group widget state", () => {
|
|
297
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
298
|
+
try {
|
|
299
|
+
const state = createState();
|
|
300
|
+
const recorder = createEventRecorder();
|
|
301
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot);
|
|
302
|
+
|
|
303
|
+
tracker.handleStarted({
|
|
304
|
+
id: "run-parallel-start",
|
|
305
|
+
asyncDir: path.join(asyncRoot, "run-parallel-start"),
|
|
306
|
+
agent: "scout",
|
|
307
|
+
agents: ["scout", "reviewer", "worker", "writer"],
|
|
308
|
+
chain: ["[scout+reviewer+worker]", "writer"],
|
|
309
|
+
chainStepCount: 2,
|
|
310
|
+
parallelGroups: [{ start: 0, count: 3, stepIndex: 0 }],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const job = state.asyncJobs.get("run-parallel-start");
|
|
314
|
+
assert.deepEqual(job?.agents, ["scout", "reviewer", "worker"]);
|
|
315
|
+
assert.equal(job?.chainStepCount, 2);
|
|
316
|
+
assert.deepEqual(job?.parallelGroups, [{ start: 0, count: 3, stepIndex: 0 }]);
|
|
317
|
+
assert.equal(job?.stepsTotal, 3);
|
|
318
|
+
assert.equal(job?.activeParallelGroup, true);
|
|
319
|
+
} finally {
|
|
320
|
+
removeTempDir(asyncRoot);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("adds flat step indexes to polled active parallel group steps", async () => {
|
|
325
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
326
|
+
try {
|
|
327
|
+
const runDir = path.join(asyncRoot, "run-chain");
|
|
328
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
329
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
330
|
+
runId: "run-chain",
|
|
331
|
+
mode: "chain",
|
|
332
|
+
state: "running",
|
|
333
|
+
startedAt: Date.now() - 1000,
|
|
334
|
+
lastUpdate: Date.now(),
|
|
335
|
+
currentStep: 1,
|
|
336
|
+
chainStepCount: 3,
|
|
337
|
+
parallelGroups: [{ start: 1, count: 2, stepIndex: 1 }],
|
|
338
|
+
steps: [
|
|
339
|
+
{ agent: "scout", status: "complete" },
|
|
340
|
+
{
|
|
341
|
+
agent: "reviewer",
|
|
342
|
+
status: "running",
|
|
343
|
+
currentTool: "read",
|
|
344
|
+
currentToolArgs: "src/tui/render.ts",
|
|
345
|
+
recentTools: [{ tool: "grep", args: "async widget", endMs: Date.now() - 100 }],
|
|
346
|
+
recentOutput: ["reviewer line"],
|
|
347
|
+
},
|
|
348
|
+
{ agent: "auditor", status: "running" },
|
|
349
|
+
{ agent: "writer", status: "pending" },
|
|
350
|
+
],
|
|
351
|
+
}), "utf-8");
|
|
352
|
+
|
|
353
|
+
const state = createState();
|
|
354
|
+
const ui = createUiContext();
|
|
355
|
+
const recorder = createEventRecorder();
|
|
356
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
357
|
+
pollIntervalMs: 10,
|
|
358
|
+
});
|
|
359
|
+
tracker.resetJobs(ui.ctx as never);
|
|
360
|
+
tracker.handleStarted({ id: "run-chain", asyncDir: runDir, mode: "chain", agents: ["scout", "reviewer", "auditor", "writer"] });
|
|
361
|
+
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
363
|
+
|
|
364
|
+
const job = state.asyncJobs.get("run-chain");
|
|
365
|
+
assert.deepEqual(job?.steps?.map((step: { index?: number }) => step.index), [1, 2]);
|
|
366
|
+
assert.deepEqual(job?.agents, ["reviewer", "auditor"]);
|
|
367
|
+
assert.equal(job?.steps?.[0]?.currentTool, "read");
|
|
368
|
+
assert.equal(job?.steps?.[0]?.currentToolArgs, "src/tui/render.ts");
|
|
369
|
+
assert.deepEqual(job?.steps?.[0]?.recentTools?.map((tool: { tool: string; args: string }) => ({ tool: tool.tool, args: tool.args })), [{ tool: "grep", args: "async widget" }]);
|
|
370
|
+
assert.deepEqual(job?.steps?.[0]?.recentOutput, ["reviewer line"]);
|
|
371
|
+
} finally {
|
|
372
|
+
removeTempDir(asyncRoot);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("rerenders changed polled status but not unchanged bookkeeping", async () => {
|
|
377
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
378
|
+
try {
|
|
379
|
+
const runDir = path.join(asyncRoot, "run-unchanged");
|
|
380
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
381
|
+
const writeStatus = (lastUpdate: number, toolCount?: number) => fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
382
|
+
runId: "run-unchanged",
|
|
383
|
+
mode: "single",
|
|
384
|
+
state: "running",
|
|
385
|
+
startedAt: 1000,
|
|
386
|
+
lastUpdate,
|
|
387
|
+
...(toolCount !== undefined ? { toolCount } : {}),
|
|
388
|
+
steps: [{ agent: "worker", status: "running", startedAt: 1000 }],
|
|
389
|
+
}), "utf-8");
|
|
390
|
+
writeStatus(2000);
|
|
391
|
+
|
|
392
|
+
const state = createState();
|
|
393
|
+
const ui = createUiContext();
|
|
394
|
+
const recorder = createEventRecorder();
|
|
395
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
396
|
+
pollIntervalMs: 10,
|
|
397
|
+
});
|
|
398
|
+
tracker.resetJobs(ui.ctx as never);
|
|
399
|
+
tracker.handleStarted({ id: "run-unchanged", asyncDir: runDir, agent: "worker" });
|
|
400
|
+
|
|
401
|
+
const requestsAfterStart = ui.renderRequests;
|
|
402
|
+
await new Promise((resolve) => setTimeout(resolve, 35));
|
|
403
|
+
assert.ok(ui.renderRequests > requestsAfterStart, "first status load should redraw the widget");
|
|
404
|
+
|
|
405
|
+
const requestsAfterStatusLoaded = ui.renderRequests;
|
|
406
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${JSON.stringify({
|
|
407
|
+
type: "subagent.control",
|
|
408
|
+
channels: ["event"],
|
|
409
|
+
event: {
|
|
410
|
+
type: "needs_attention",
|
|
411
|
+
to: "needs_attention",
|
|
412
|
+
ts: 123,
|
|
413
|
+
runId: "run-unchanged",
|
|
414
|
+
agent: "worker",
|
|
415
|
+
message: "worker needs attention",
|
|
416
|
+
},
|
|
417
|
+
})}\n`, "utf-8");
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
419
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-event"), true);
|
|
420
|
+
assert.equal(ui.renderRequests, requestsAfterStatusLoaded, "unchanged status and control cursors should not request widget redraws");
|
|
421
|
+
|
|
422
|
+
writeStatus(3000, 1);
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
424
|
+
assert.ok(ui.renderRequests > requestsAfterStatusLoaded, "changed non-terminal status should redraw the widget");
|
|
425
|
+
} finally {
|
|
426
|
+
removeTempDir(asyncRoot);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("schedules cleanup when polling observes a completed status without a completion event", async () => {
|
|
431
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
432
|
+
try {
|
|
433
|
+
const runDir = path.join(asyncRoot, "run-2");
|
|
434
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
435
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
436
|
+
runId: "run-2",
|
|
437
|
+
mode: "single",
|
|
438
|
+
state: "complete",
|
|
439
|
+
startedAt: Date.now() - 1000,
|
|
440
|
+
lastUpdate: Date.now(),
|
|
441
|
+
steps: [{ agent: "worker", status: "complete" }],
|
|
442
|
+
}), "utf-8");
|
|
443
|
+
|
|
444
|
+
const state = createState();
|
|
445
|
+
const ui = createUiContext();
|
|
446
|
+
const recorder = createEventRecorder();
|
|
447
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
448
|
+
completionRetentionMs: 5,
|
|
449
|
+
pollIntervalMs: 10,
|
|
450
|
+
});
|
|
451
|
+
tracker.resetJobs(ui.ctx as never);
|
|
452
|
+
tracker.handleStarted({ id: "run-2", asyncDir: runDir, agent: "worker" });
|
|
453
|
+
|
|
454
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
455
|
+
|
|
456
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
457
|
+
assert.ok(ui.renderRequests > 0, "expected polling cleanup to request a rerender");
|
|
458
|
+
assert.equal(ui.widgets.at(-1), undefined);
|
|
459
|
+
} finally {
|
|
460
|
+
removeTempDir(asyncRoot);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("repairs stale running jobs during polling", async () => {
|
|
465
|
+
const asyncRoot = createTempDir("pi-async-job-stale-");
|
|
466
|
+
try {
|
|
467
|
+
const resultsDir = path.join(asyncRoot, "results");
|
|
468
|
+
const runDir = path.join(asyncRoot, "run-stale");
|
|
469
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
470
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
471
|
+
runId: "run-stale",
|
|
472
|
+
mode: "single",
|
|
473
|
+
state: "running",
|
|
474
|
+
pid: 12345,
|
|
475
|
+
startedAt: Date.now() - 1000,
|
|
476
|
+
lastUpdate: Date.now() - 1000,
|
|
477
|
+
steps: [{ agent: "worker", status: "running", startedAt: Date.now() - 1000 }],
|
|
478
|
+
}), "utf-8");
|
|
479
|
+
|
|
480
|
+
const state = createState();
|
|
481
|
+
const ui = createUiContext();
|
|
482
|
+
const recorder = createEventRecorder();
|
|
483
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
484
|
+
completionRetentionMs: 5,
|
|
485
|
+
pollIntervalMs: 10,
|
|
486
|
+
resultsDir,
|
|
487
|
+
kill: pidGone,
|
|
488
|
+
now: () => Date.now(),
|
|
489
|
+
});
|
|
490
|
+
tracker.resetJobs(ui.ctx as never);
|
|
491
|
+
tracker.handleStarted({ id: "run-stale", asyncDir: runDir, agent: "worker" });
|
|
492
|
+
|
|
493
|
+
await waitForCondition(() => state.asyncJobs.size === 0, "stale async job cleanup");
|
|
494
|
+
|
|
495
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
496
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(runDir, "status.json"), "utf-8")).state, "failed");
|
|
497
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(resultsDir, "run-stale.json"), "utf-8")).success, false);
|
|
498
|
+
assert.ok(ui.renderRequests > 0, "expected stale repair cleanup to request a rerender");
|
|
499
|
+
} finally {
|
|
500
|
+
removeTempDir(asyncRoot);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("repairs started jobs whose runner dies before writing status", async () => {
|
|
505
|
+
const asyncRoot = createTempDir("pi-async-job-no-status-");
|
|
506
|
+
try {
|
|
507
|
+
const resultsDir = path.join(asyncRoot, "results");
|
|
508
|
+
const runDir = path.join(asyncRoot, "run-no-status");
|
|
509
|
+
const state = createState();
|
|
510
|
+
const ui = createUiContext();
|
|
511
|
+
const recorder = createEventRecorder();
|
|
512
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
513
|
+
completionRetentionMs: 5,
|
|
514
|
+
pollIntervalMs: 10,
|
|
515
|
+
resultsDir,
|
|
516
|
+
kill: pidGone,
|
|
517
|
+
now: () => Date.now() + 2000,
|
|
518
|
+
});
|
|
519
|
+
tracker.resetJobs(ui.ctx as never);
|
|
520
|
+
tracker.handleStarted({
|
|
521
|
+
id: "run-no-status",
|
|
522
|
+
asyncDir: runDir,
|
|
523
|
+
pid: 12345,
|
|
524
|
+
sessionId: "session-current",
|
|
525
|
+
mode: "parallel",
|
|
526
|
+
agents: ["scout", "reviewer", "worker"],
|
|
527
|
+
chainStepCount: 1,
|
|
528
|
+
parallelGroups: [{ start: 0, count: 3, stepIndex: 0 }],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
532
|
+
|
|
533
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
534
|
+
const status = JSON.parse(fs.readFileSync(path.join(runDir, "status.json"), "utf-8"));
|
|
535
|
+
const result = JSON.parse(fs.readFileSync(path.join(resultsDir, "run-no-status.json"), "utf-8"));
|
|
536
|
+
assert.equal(status.state, "failed");
|
|
537
|
+
assert.equal(status.sessionId, "session-current");
|
|
538
|
+
assert.equal(status.mode, "parallel");
|
|
539
|
+
assert.equal(status.currentStep, 0);
|
|
540
|
+
assert.equal(status.chainStepCount, 1);
|
|
541
|
+
assert.deepEqual(status.parallelGroups, [{ start: 0, count: 3, stepIndex: 0 }]);
|
|
542
|
+
assert.deepEqual(status.steps.map((step: { agent: string; status: string }) => [step.agent, step.status]), [
|
|
543
|
+
["scout", "failed"],
|
|
544
|
+
["reviewer", "failed"],
|
|
545
|
+
["worker", "failed"],
|
|
546
|
+
]);
|
|
547
|
+
assert.equal(result.success, false);
|
|
548
|
+
assert.equal(result.sessionId, "session-current");
|
|
549
|
+
assert.ok(ui.renderRequests > 0, "expected startup-crash repair cleanup to request a rerender");
|
|
550
|
+
} finally {
|
|
551
|
+
removeTempDir(asyncRoot);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("cleans up jobs when status polling hits a terminal read error", async () => {
|
|
556
|
+
const asyncRoot = createTempDir("pi-async-job-bad-status-");
|
|
557
|
+
try {
|
|
558
|
+
const runDir = path.join(asyncRoot, "run-bad-status");
|
|
559
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
560
|
+
fs.writeFileSync(path.join(runDir, "status.json"), "{", "utf-8");
|
|
561
|
+
const state = createState();
|
|
562
|
+
const ui = createUiContext();
|
|
563
|
+
const recorder = createEventRecorder();
|
|
564
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
565
|
+
completionRetentionMs: 5,
|
|
566
|
+
pollIntervalMs: 10,
|
|
567
|
+
});
|
|
568
|
+
tracker.resetJobs(ui.ctx as never);
|
|
569
|
+
tracker.handleStarted({ id: "run-bad-status", asyncDir: runDir, agent: "worker" });
|
|
570
|
+
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
572
|
+
|
|
573
|
+
assert.equal(state.asyncJobs.size, 0);
|
|
574
|
+
assert.ok(ui.renderRequests > 0, "expected malformed status cleanup to request a rerender");
|
|
575
|
+
} finally {
|
|
576
|
+
removeTempDir(asyncRoot);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("does not clean up a status-read failure while nested descendants are live", async () => {
|
|
581
|
+
const asyncRoot = createTempDir("pi-async-job-bad-status-nested-");
|
|
582
|
+
let tracker: ReturnType<AsyncJobTrackerModule["createAsyncJobTracker"]> | undefined;
|
|
583
|
+
const originalError = console.error;
|
|
584
|
+
console.error = () => {};
|
|
585
|
+
try {
|
|
586
|
+
const runDir = path.join(asyncRoot, "run-bad-status-nested");
|
|
587
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
588
|
+
fs.writeFileSync(path.join(runDir, "status.json"), "{", "utf-8");
|
|
589
|
+
const state = createState();
|
|
590
|
+
const recorder = createEventRecorder();
|
|
591
|
+
tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
592
|
+
completionRetentionMs: 5,
|
|
593
|
+
pollIntervalMs: 10,
|
|
594
|
+
});
|
|
595
|
+
tracker.handleStarted({ id: "run-bad-status-nested", asyncDir: runDir, agent: "worker" });
|
|
596
|
+
const job = state.asyncJobs.get("run-bad-status-nested");
|
|
597
|
+
assert.ok(job);
|
|
598
|
+
job.nestedChildren = [{
|
|
599
|
+
id: "nested-live",
|
|
600
|
+
parentRunId: "run-bad-status-nested",
|
|
601
|
+
depth: 1,
|
|
602
|
+
path: [{ runId: "run-bad-status-nested" }],
|
|
603
|
+
state: "running",
|
|
604
|
+
agent: "nested-worker",
|
|
605
|
+
}];
|
|
606
|
+
|
|
607
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
608
|
+
|
|
609
|
+
assert.equal(state.asyncJobs.has("run-bad-status-nested"), true);
|
|
610
|
+
assert.equal(state.asyncJobs.get("run-bad-status-nested")?.status, "failed");
|
|
611
|
+
assert.equal(state.cleanupTimers.has("run-bad-status-nested"), false);
|
|
612
|
+
} finally {
|
|
613
|
+
console.error = originalError;
|
|
614
|
+
tracker?.resetJobs();
|
|
615
|
+
removeTempDir(asyncRoot);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("keeps root jobs running when nested refresh fails during polling", async () => {
|
|
620
|
+
const asyncRoot = createTempDir("pi-async-job-nested-refresh-um");
|
|
621
|
+
let tracker: ReturnType<AsyncJobTrackerModule["createAsyncJobTracker"]> | undefined;
|
|
622
|
+
const originalError = console.error;
|
|
623
|
+
console.error = () => {};
|
|
624
|
+
try {
|
|
625
|
+
const runDir = path.join(asyncRoot, "run-nested-refresh");
|
|
626
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
627
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
628
|
+
runId: "run-nested-refresh",
|
|
629
|
+
mode: "single",
|
|
630
|
+
state: "running",
|
|
631
|
+
startedAt: Date.now() - 1000,
|
|
632
|
+
lastUpdate: Date.now(),
|
|
633
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
634
|
+
}), "utf-8");
|
|
635
|
+
|
|
636
|
+
const state = createState();
|
|
637
|
+
const recorder = createEventRecorder();
|
|
638
|
+
tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
639
|
+
completionRetentionMs: 5,
|
|
640
|
+
pollIntervalMs: 10,
|
|
641
|
+
});
|
|
642
|
+
tracker.handleStarted({
|
|
643
|
+
id: "run-nested-refresh",
|
|
644
|
+
asyncDir: runDir,
|
|
645
|
+
agent: "worker",
|
|
646
|
+
nestedRoute: {
|
|
647
|
+
rootRunId: "run-nested-refresh",
|
|
648
|
+
eventSink: path.join(asyncRoot, "not-contained-events"),
|
|
649
|
+
controlInbox: path.join(asyncRoot, "not-contained-controls"),
|
|
650
|
+
capabilityToken: "bad-token",
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
655
|
+
|
|
656
|
+
assert.equal(state.asyncJobs.get("run-nested-refresh")?.status, "running");
|
|
657
|
+
assert.equal(state.cleanupTimers.has("run-nested-refresh"), false);
|
|
658
|
+
} finally {
|
|
659
|
+
console.error = originalError;
|
|
660
|
+
tracker?.resetJobs();
|
|
661
|
+
removeTempDir(asyncRoot);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("cancels cleanup timers when polling observes a non-terminal status", async () => {
|
|
666
|
+
const asyncRoot = createTempDir("pi-async-job-cleanup-cancel-");
|
|
667
|
+
let tracker: ReturnType<AsyncJobTrackerModule["createAsyncJobTracker"]> | undefined;
|
|
668
|
+
try {
|
|
669
|
+
const runDir = path.join(asyncRoot, "run-recovered");
|
|
670
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
671
|
+
const state = createState();
|
|
672
|
+
const recorder = createEventRecorder();
|
|
673
|
+
tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
674
|
+
completionRetentionMs: 1_000,
|
|
675
|
+
pollIntervalMs: 10,
|
|
676
|
+
});
|
|
677
|
+
tracker.handleStarted({ id: "run-recovered", asyncDir: runDir, agent: "worker" });
|
|
678
|
+
tracker.handleComplete({ id: "run-recovered", success: true });
|
|
679
|
+
assert.equal(state.cleanupTimers.has("run-recovered"), true);
|
|
680
|
+
|
|
681
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
682
|
+
runId: "run-recovered",
|
|
683
|
+
mode: "single",
|
|
684
|
+
state: "running",
|
|
685
|
+
startedAt: Date.now() - 1000,
|
|
686
|
+
lastUpdate: Date.now(),
|
|
687
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
688
|
+
}), "utf-8");
|
|
689
|
+
|
|
690
|
+
const deadline = Date.now() + 200;
|
|
691
|
+
while (Date.now() < deadline && state.cleanupTimers.has("run-recovered")) {
|
|
692
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
assert.equal(state.cleanupTimers.has("run-recovered"), false);
|
|
696
|
+
assert.equal(state.asyncJobs.get("run-recovered")?.status, "running");
|
|
697
|
+
} finally {
|
|
698
|
+
tracker?.resetJobs();
|
|
699
|
+
removeTempDir(asyncRoot);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("keeps incomplete async control event lines for the next poll", async () => {
|
|
704
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
705
|
+
try {
|
|
706
|
+
const runDir = path.join(asyncRoot, "run-partial");
|
|
707
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
708
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
709
|
+
runId: "run-partial",
|
|
710
|
+
mode: "single",
|
|
711
|
+
state: "running",
|
|
712
|
+
startedAt: Date.now() - 1000,
|
|
713
|
+
lastUpdate: Date.now(),
|
|
714
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
715
|
+
}), "utf-8");
|
|
716
|
+
const eventPath = path.join(runDir, "events.jsonl");
|
|
717
|
+
const partialRecord = JSON.stringify({
|
|
718
|
+
type: "subagent.control",
|
|
719
|
+
channels: ["event"],
|
|
720
|
+
event: {
|
|
721
|
+
type: "needs_attention",
|
|
722
|
+
to: "needs_attention",
|
|
723
|
+
ts: 123,
|
|
724
|
+
runId: "run-partial",
|
|
725
|
+
agent: "worker",
|
|
726
|
+
message: "worker needs attention",
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
fs.writeFileSync(eventPath, partialRecord, "utf-8");
|
|
730
|
+
|
|
731
|
+
const state = createState();
|
|
732
|
+
const recorder = createEventRecorder();
|
|
733
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
734
|
+
pollIntervalMs: 10,
|
|
735
|
+
});
|
|
736
|
+
tracker.handleStarted({ id: "run-partial", asyncDir: runDir, agent: "worker" });
|
|
737
|
+
|
|
738
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
739
|
+
assert.equal(recorder.events.length, 0);
|
|
740
|
+
|
|
741
|
+
fs.appendFileSync(eventPath, "\n", "utf-8");
|
|
742
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
743
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-event"), true);
|
|
744
|
+
} finally {
|
|
745
|
+
removeTempDir(asyncRoot);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("scans async control events in bounded chunks", async () => {
|
|
750
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
751
|
+
const originalAlloc = Buffer.alloc;
|
|
752
|
+
const allocationSizes: number[] = [];
|
|
753
|
+
try {
|
|
754
|
+
const runDir = path.join(asyncRoot, "run-chunked-control");
|
|
755
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
756
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
757
|
+
runId: "run-chunked-control",
|
|
758
|
+
mode: "single",
|
|
759
|
+
state: "running",
|
|
760
|
+
startedAt: Date.now() - 1000,
|
|
761
|
+
lastUpdate: Date.now(),
|
|
762
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
763
|
+
}), "utf-8");
|
|
764
|
+
const largeDiagnostic = JSON.stringify({
|
|
765
|
+
type: "message_update",
|
|
766
|
+
message: { role: "assistant", content: [{ type: "text", text: "x".repeat(200_000) }] },
|
|
767
|
+
});
|
|
768
|
+
const controlEvent = JSON.stringify({
|
|
769
|
+
type: "subagent.control",
|
|
770
|
+
channels: ["event"],
|
|
771
|
+
event: {
|
|
772
|
+
type: "needs_attention",
|
|
773
|
+
to: "needs_attention",
|
|
774
|
+
ts: 123,
|
|
775
|
+
runId: "run-chunked-control",
|
|
776
|
+
agent: "worker",
|
|
777
|
+
message: "worker needs attention",
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${largeDiagnostic}\n${controlEvent}\n`, "utf-8");
|
|
781
|
+
|
|
782
|
+
Buffer.alloc = ((size: number, fill?: string | Buffer | number, encoding?: BufferEncoding) => {
|
|
783
|
+
allocationSizes.push(size);
|
|
784
|
+
return originalAlloc(size, fill as never, encoding);
|
|
785
|
+
}) as typeof Buffer.alloc;
|
|
786
|
+
|
|
787
|
+
const state = createState();
|
|
788
|
+
const recorder = createEventRecorder();
|
|
789
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
790
|
+
pollIntervalMs: 10,
|
|
791
|
+
});
|
|
792
|
+
tracker.handleStarted({ id: "run-chunked-control", asyncDir: runDir, agent: "worker" });
|
|
793
|
+
|
|
794
|
+
await waitForCondition(
|
|
795
|
+
() => recorder.events.some((event) => event.channel === "subagent:control-event"),
|
|
796
|
+
"chunked control event",
|
|
797
|
+
);
|
|
798
|
+
assert.ok(allocationSizes.length > 0, "expected the tracker to allocate read buffers");
|
|
799
|
+
assert.equal(Math.max(...allocationSizes) <= 64 * 1024, true);
|
|
800
|
+
} finally {
|
|
801
|
+
Buffer.alloc = originalAlloc;
|
|
802
|
+
removeTempDir(asyncRoot);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("does not tail-skip control events for newly tracked large logs", async () => {
|
|
807
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
808
|
+
try {
|
|
809
|
+
const runDir = path.join(asyncRoot, "run-new-large-control");
|
|
810
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
811
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
812
|
+
runId: "run-new-large-control",
|
|
813
|
+
mode: "single",
|
|
814
|
+
state: "running",
|
|
815
|
+
startedAt: Date.now() - 1000,
|
|
816
|
+
lastUpdate: Date.now(),
|
|
817
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
818
|
+
}), "utf-8");
|
|
819
|
+
const controlEvent = JSON.stringify({
|
|
820
|
+
type: "subagent.control",
|
|
821
|
+
channels: ["event"],
|
|
822
|
+
event: {
|
|
823
|
+
type: "needs_attention",
|
|
824
|
+
to: "needs_attention",
|
|
825
|
+
ts: 123,
|
|
826
|
+
runId: "run-new-large-control",
|
|
827
|
+
agent: "worker",
|
|
828
|
+
message: "worker needs attention",
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
const diagnosticLine = JSON.stringify({
|
|
832
|
+
type: "message_update",
|
|
833
|
+
message: { role: "assistant", content: [{ type: "text", text: "x".repeat(4000) }] },
|
|
834
|
+
}) + "\n";
|
|
835
|
+
const eventsPath = path.join(runDir, "events.jsonl");
|
|
836
|
+
fs.writeFileSync(eventsPath, controlEvent + "\n" + diagnosticLine.repeat(900), "utf-8");
|
|
837
|
+
assert.ok(fs.statSync(eventsPath).size > 2 * 1024 * 1024, "test fixture should exceed the legacy scan window");
|
|
838
|
+
|
|
839
|
+
const state = createState();
|
|
840
|
+
const recorder = createEventRecorder();
|
|
841
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
842
|
+
pollIntervalMs: 10,
|
|
843
|
+
});
|
|
844
|
+
tracker.handleStarted({ id: "run-new-large-control", asyncDir: runDir, agent: "worker" });
|
|
845
|
+
|
|
846
|
+
await waitForCondition(
|
|
847
|
+
() => recorder.events.some((event) => event.channel === "subagent:control-event"),
|
|
848
|
+
"new large log control event",
|
|
849
|
+
);
|
|
850
|
+
} finally {
|
|
851
|
+
removeTempDir(asyncRoot);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("starts large legacy control-event scans from a bounded tail window", async () => {
|
|
856
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
857
|
+
const originalAlloc = Buffer.alloc;
|
|
858
|
+
const originalError = console.error;
|
|
859
|
+
const allocationSizes: number[] = [];
|
|
860
|
+
console.error = () => {};
|
|
861
|
+
try {
|
|
862
|
+
const runDir = path.join(asyncRoot, "run-large-legacy-control");
|
|
863
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
864
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
865
|
+
runId: "run-large-legacy-control",
|
|
866
|
+
mode: "single",
|
|
867
|
+
state: "running",
|
|
868
|
+
startedAt: Date.now() - 1000,
|
|
869
|
+
lastUpdate: Date.now(),
|
|
870
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
871
|
+
}), "utf-8");
|
|
872
|
+
const diagnosticLine = JSON.stringify({
|
|
873
|
+
type: "message_update",
|
|
874
|
+
message: { role: "assistant", content: [{ type: "text", text: "x".repeat(4000) }] },
|
|
875
|
+
}) + "\n";
|
|
876
|
+
const controlEvent = JSON.stringify({
|
|
877
|
+
type: "subagent.control",
|
|
878
|
+
channels: ["event"],
|
|
879
|
+
event: {
|
|
880
|
+
type: "needs_attention",
|
|
881
|
+
to: "needs_attention",
|
|
882
|
+
ts: 123,
|
|
883
|
+
runId: "run-large-legacy-control",
|
|
884
|
+
agent: "worker",
|
|
885
|
+
message: "worker needs attention",
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
const eventsPath = path.join(runDir, "events.jsonl");
|
|
889
|
+
fs.writeFileSync(eventsPath, diagnosticLine.repeat(900) + controlEvent + "\n", "utf-8");
|
|
890
|
+
const eventLogBytes = fs.statSync(eventsPath).size;
|
|
891
|
+
assert.ok(eventLogBytes > 2 * 1024 * 1024, "test fixture should exceed the scan window");
|
|
892
|
+
|
|
893
|
+
Buffer.alloc = ((size: number, fill?: string | Buffer | number, encoding?: BufferEncoding) => {
|
|
894
|
+
allocationSizes.push(size);
|
|
895
|
+
return originalAlloc(size, fill as never, encoding);
|
|
896
|
+
}) as typeof Buffer.alloc;
|
|
897
|
+
|
|
898
|
+
const state = createState();
|
|
899
|
+
state.asyncJobs.set("run-large-legacy-control", {
|
|
900
|
+
asyncId: "run-large-legacy-control",
|
|
901
|
+
asyncDir: runDir,
|
|
902
|
+
status: "running",
|
|
903
|
+
agents: ["worker"],
|
|
904
|
+
startedAt: Date.now() - 1000,
|
|
905
|
+
updatedAt: Date.now(),
|
|
906
|
+
});
|
|
907
|
+
const recorder = createEventRecorder();
|
|
908
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
909
|
+
pollIntervalMs: 10,
|
|
910
|
+
});
|
|
911
|
+
tracker.ensurePoller();
|
|
912
|
+
|
|
913
|
+
await waitForCondition(
|
|
914
|
+
() => recorder.events.some((event) => event.channel === "subagent:control-event"),
|
|
915
|
+
"tail-window control event",
|
|
916
|
+
);
|
|
917
|
+
assert.ok(allocationSizes.length > 0, "expected the tracker to allocate read buffers");
|
|
918
|
+
assert.equal(Math.max(...allocationSizes) <= 64 * 1024, true);
|
|
919
|
+
const totalAllocated = allocationSizes.reduce((sum, size) => sum + size, 0);
|
|
920
|
+
assert.ok(totalAllocated < eventLogBytes, "scan should not read the full legacy event log");
|
|
921
|
+
assert.ok(totalAllocated <= 2 * 1024 * 1024 + 64 * 1024, "scan should stay within the bounded tail window");
|
|
922
|
+
} finally {
|
|
923
|
+
Buffer.alloc = originalAlloc;
|
|
924
|
+
console.error = originalError;
|
|
925
|
+
removeTempDir(asyncRoot);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("clears transient current tool fields when status clears them", async () => {
|
|
930
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
931
|
+
try {
|
|
932
|
+
const runDir = path.join(asyncRoot, "run-clear-tool");
|
|
933
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
934
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
935
|
+
runId: "run-clear-tool",
|
|
936
|
+
mode: "single",
|
|
937
|
+
state: "running",
|
|
938
|
+
startedAt: Date.now() - 1000,
|
|
939
|
+
lastUpdate: Date.now(),
|
|
940
|
+
currentTool: "edit",
|
|
941
|
+
currentToolStartedAt: Date.now() - 100,
|
|
942
|
+
currentPath: "src/runs/background/subagent-runner.ts",
|
|
943
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
944
|
+
}), "utf-8");
|
|
945
|
+
|
|
946
|
+
const state = createState();
|
|
947
|
+
const recorder = createEventRecorder();
|
|
948
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
949
|
+
pollIntervalMs: 10,
|
|
950
|
+
});
|
|
951
|
+
tracker.handleStarted({ id: "run-clear-tool", asyncDir: runDir, agent: "worker" });
|
|
952
|
+
|
|
953
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
954
|
+
let job = state.asyncJobs.get("run-clear-tool");
|
|
955
|
+
assert.equal(job?.currentTool, "edit");
|
|
956
|
+
assert.equal(job?.currentPath, "src/runs/background/subagent-runner.ts");
|
|
957
|
+
|
|
958
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
959
|
+
runId: "run-clear-tool",
|
|
960
|
+
mode: "single",
|
|
961
|
+
state: "running",
|
|
962
|
+
startedAt: Date.now() - 1000,
|
|
963
|
+
lastUpdate: Date.now(),
|
|
964
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
965
|
+
}), "utf-8");
|
|
966
|
+
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
968
|
+
job = state.asyncJobs.get("run-clear-tool");
|
|
969
|
+
assert.equal(job?.currentTool, undefined);
|
|
970
|
+
assert.equal(job?.currentToolStartedAt, undefined);
|
|
971
|
+
assert.equal(job?.currentPath, undefined);
|
|
972
|
+
} finally {
|
|
973
|
+
removeTempDir(asyncRoot);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("honors async control notification channels", async () => {
|
|
978
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
979
|
+
try {
|
|
980
|
+
const runDir = path.join(asyncRoot, "run-channels");
|
|
981
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
982
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
983
|
+
runId: "run-channels",
|
|
984
|
+
mode: "single",
|
|
985
|
+
state: "running",
|
|
986
|
+
startedAt: Date.now() - 1000,
|
|
987
|
+
lastUpdate: Date.now(),
|
|
988
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
989
|
+
}), "utf-8");
|
|
990
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${JSON.stringify({
|
|
991
|
+
type: "subagent.control",
|
|
992
|
+
channels: ["intercom"],
|
|
993
|
+
event: {
|
|
994
|
+
type: "needs_attention",
|
|
995
|
+
to: "needs_attention",
|
|
996
|
+
ts: 123,
|
|
997
|
+
runId: "run-channels",
|
|
998
|
+
agent: "worker",
|
|
999
|
+
message: "worker needs attention",
|
|
1000
|
+
},
|
|
1001
|
+
intercom: { to: "main", message: "SUBAGENT NEEDS ATTENTION: worker in run run-channels." },
|
|
1002
|
+
})}\n`, "utf-8");
|
|
1003
|
+
|
|
1004
|
+
const state = createState();
|
|
1005
|
+
const recorder = createEventRecorder();
|
|
1006
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
1007
|
+
pollIntervalMs: 10,
|
|
1008
|
+
});
|
|
1009
|
+
tracker.handleStarted({ id: "run-channels", asyncDir: runDir, agent: "worker" });
|
|
1010
|
+
|
|
1011
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1012
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-event"), false);
|
|
1013
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-intercom"), true);
|
|
1014
|
+
} finally {
|
|
1015
|
+
removeTempDir(asyncRoot);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("does not bridge active-long-running records to intercom", async () => {
|
|
1020
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
1021
|
+
try {
|
|
1022
|
+
const runDir = path.join(asyncRoot, "run-active-intercom");
|
|
1023
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
1024
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
1025
|
+
runId: "run-active-intercom",
|
|
1026
|
+
mode: "single",
|
|
1027
|
+
state: "running",
|
|
1028
|
+
startedAt: Date.now() - 1000,
|
|
1029
|
+
lastUpdate: Date.now(),
|
|
1030
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
1031
|
+
}), "utf-8");
|
|
1032
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${JSON.stringify({
|
|
1033
|
+
type: "subagent.control",
|
|
1034
|
+
channels: ["event", "intercom"],
|
|
1035
|
+
event: {
|
|
1036
|
+
type: "active_long_running",
|
|
1037
|
+
to: "active_long_running",
|
|
1038
|
+
ts: 123,
|
|
1039
|
+
runId: "run-active-intercom",
|
|
1040
|
+
agent: "worker",
|
|
1041
|
+
message: "worker is still active but long-running",
|
|
1042
|
+
},
|
|
1043
|
+
intercom: { to: "main", message: "stale active notice" },
|
|
1044
|
+
})}\n`, "utf-8");
|
|
1045
|
+
|
|
1046
|
+
const state = createState();
|
|
1047
|
+
const recorder = createEventRecorder();
|
|
1048
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
1049
|
+
pollIntervalMs: 10,
|
|
1050
|
+
});
|
|
1051
|
+
tracker.handleStarted({ id: "run-active-intercom", asyncDir: runDir, agent: "worker" });
|
|
1052
|
+
|
|
1053
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1054
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-event"), true);
|
|
1055
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-intercom"), false);
|
|
1056
|
+
} finally {
|
|
1057
|
+
removeTempDir(asyncRoot);
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("bridges async control events from events.jsonl to the parent event bus", async () => {
|
|
1062
|
+
const asyncRoot = createTempDir("pi-async-job-tracker-");
|
|
1063
|
+
try {
|
|
1064
|
+
const runDir = path.join(asyncRoot, "run-3");
|
|
1065
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
1066
|
+
fs.writeFileSync(path.join(runDir, "status.json"), JSON.stringify({
|
|
1067
|
+
runId: "run-3",
|
|
1068
|
+
mode: "single",
|
|
1069
|
+
state: "running",
|
|
1070
|
+
startedAt: Date.now() - 1000,
|
|
1071
|
+
lastUpdate: Date.now(),
|
|
1072
|
+
steps: [{ agent: "worker", status: "running" }],
|
|
1073
|
+
}), "utf-8");
|
|
1074
|
+
fs.writeFileSync(path.join(runDir, "events.jsonl"), `${JSON.stringify({
|
|
1075
|
+
type: "subagent.control",
|
|
1076
|
+
channels: ["event", "intercom"],
|
|
1077
|
+
childIntercomTarget: "subagent-worker-run-3-1",
|
|
1078
|
+
noticeText: "Subagent needs attention: worker\nNudge: intercom({ action: \"send\", to: \"subagent-worker-run-3-1\", message: \"<message>\" })",
|
|
1079
|
+
event: {
|
|
1080
|
+
type: "needs_attention",
|
|
1081
|
+
to: "needs_attention",
|
|
1082
|
+
ts: 123,
|
|
1083
|
+
runId: "run-3",
|
|
1084
|
+
agent: "worker",
|
|
1085
|
+
message: "worker needs attention",
|
|
1086
|
+
},
|
|
1087
|
+
intercom: { to: "main", message: "SUBAGENT NEEDS ATTENTION: worker in run run-3." },
|
|
1088
|
+
})}\n`, "utf-8");
|
|
1089
|
+
|
|
1090
|
+
const state = createState();
|
|
1091
|
+
const recorder = createEventRecorder();
|
|
1092
|
+
const tracker = trackerMod!.createAsyncJobTracker(recorder.pi, state as never, asyncRoot, {
|
|
1093
|
+
pollIntervalMs: 10,
|
|
1094
|
+
});
|
|
1095
|
+
tracker.handleStarted({ id: "run-3", asyncDir: runDir, agent: "worker" });
|
|
1096
|
+
|
|
1097
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
1098
|
+
|
|
1099
|
+
const controlEvent = recorder.events.find((event) => event.channel === "subagent:control-event");
|
|
1100
|
+
assert.ok(controlEvent);
|
|
1101
|
+
assert.match((controlEvent.data as { noticeText?: string }).noticeText ?? "", /subagent-worker-run-3-1/);
|
|
1102
|
+
assert.equal(recorder.events.some((event) => event.channel === "subagent:control-intercom"), true);
|
|
1103
|
+
} finally {
|
|
1104
|
+
removeTempDir(asyncRoot);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
});
|