@gavin-hu/my-pi-agent 0.21.0 → 0.22.0
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/README.md +46 -31
- package/README.zh-CN.md +40 -26
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -24
- package/docs/extensions/board.md +361 -147
- package/docs/extensions/bytefly.md +152 -111
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +30 -31
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +166 -126
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +34 -18
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +72 -33
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +56 -15
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +5 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +4 -40
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +13 -11
- package/extensions/board/result.ts +30 -16
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +42 -10
- package/extensions/board/snapshot.ts +2 -1
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +131 -44
- package/extensions/board/types.ts +36 -32
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +33 -15
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +89 -21
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +23 -9
- package/extensions/board/view/render.ts +208 -271
- package/extensions/board/view/styles.ts +123 -31
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +4 -2
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/lines.ts +7 -4
- package/extensions/bytefly/footer/render/segments.ts +37 -10
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/index.ts +67 -36
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +3 -3
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +53 -52
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +49 -39
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-client.ts +3 -0
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +9 -67
- package/extensions/plan/web-payload.ts +3 -3
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web.ts +1 -12
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +10 -8
- package/extensions/serve/index.ts +17 -3
- package/extensions/serve/registry.ts +26 -17
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +2 -1
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -7
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/view/todo-screen.ts +122 -0
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +27 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +1 -1
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +136 -54
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +87 -30
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +70 -58
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +14 -12
- package/lib/http/port.ts +15 -10
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +39 -10
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +8 -8
- package/lib/session-label.ts +111 -5
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +5 -4
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +23 -8
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +9 -7
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +3 -3
- package/lib/web/README.md +21 -0
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +9 -10
- package/package.json +6 -1
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/seat.ts +0 -28
- package/extensions/todo/tui.ts +0 -212
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
|
@@ -1,138 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Job runtime: the session-scoped job table and
|
|
2
|
+
* Job runtime: the session-scoped job table and the durable record.
|
|
3
3
|
*
|
|
4
|
-
* Owns the in-memory `Job` map
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `waiters.ts`.
|
|
4
|
+
* Owns the in-memory `Job` map and its transitions, and composes the smaller
|
|
5
|
+
* pieces: `child.ts` (a job's process and its output), `clock.ts` (the repaint
|
|
6
|
+
* clock and the reattached-job sweep), `store.ts` (the durable registry),
|
|
7
|
+
* `ui.ts` (status chips), and `waiters.ts`.
|
|
9
8
|
*
|
|
10
9
|
* Registry model: each session is the sole writer of its own
|
|
11
10
|
* `registry-<hash>.json`. `load` merges every session's file and adopts the
|
|
12
11
|
* records of dead peer sessions (so a detached server reattaches and a crashed
|
|
13
|
-
* session's history survives) before reconciling pids. The process
|
|
14
|
-
*
|
|
15
|
-
* tests.
|
|
12
|
+
* session's history survives) before reconciling pids. The process seams are
|
|
13
|
+
* injectable so the whole runtime can be driven by a fake child in tests.
|
|
16
14
|
*
|
|
17
15
|
* Concurrency: the runtime assumes the tool is `executionMode: "sequential"`,
|
|
18
16
|
* but job completion is asynchronous, so `finalize` is guarded against running
|
|
19
17
|
* twice and listeners/waiters are removed on cleanup.
|
|
20
18
|
*/
|
|
21
19
|
|
|
22
|
-
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
23
20
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { KILL_GRACE_MS } from "../../lib/child.ts";
|
|
22
|
+
import { abortableSleep } from "../../lib/timer.ts";
|
|
24
23
|
import { resolveEffectiveCwd } from "../../lib/env.ts";
|
|
24
|
+
import type { KillTreeFn, SpawnFn } from "../../lib/spawn.ts";
|
|
25
|
+
import { createJobChildren, type JobSeams } from "./child.ts";
|
|
26
|
+
import { createExternalSweep, createRepaintClock } from "./clock.ts";
|
|
27
|
+
import { DEFAULT_CONFIG, loadConfig, type JobsConfig } from "./config.ts";
|
|
28
|
+
import { compareJobs } from "./format.ts";
|
|
25
29
|
import {
|
|
26
|
-
|
|
27
|
-
defaultSpawn,
|
|
28
|
-
isPidAlive,
|
|
29
|
-
type KillTreeFn,
|
|
30
|
-
type SpawnedProcess,
|
|
31
|
-
type SpawnFn,
|
|
32
|
-
startTokenOf,
|
|
33
|
-
type StdinSink,
|
|
34
|
-
} from "../../lib/spawn.ts";
|
|
35
|
-
import { loadConfig, type JobsConfig } from "./config.ts";
|
|
36
|
-
import { createLogWriter, type LogHandle } from "./log-writer.ts";
|
|
37
|
-
import { sanitizeUntrustedLine } from "../../lib/format.ts";
|
|
38
|
-
import { defaultInterval } from "../../lib/timer.ts";
|
|
39
|
-
import { clipLabel } from "../../lib/tui/columns.ts";
|
|
40
|
-
import { compareJobs, formatLogs } from "./format.ts";
|
|
41
|
-
import { readLogTail } from "./logs.ts";
|
|
42
|
-
import {
|
|
30
|
+
loadRecords,
|
|
43
31
|
loadRegistries,
|
|
44
|
-
mergeRecords,
|
|
45
|
-
planReconcile,
|
|
46
32
|
registryDirFor,
|
|
47
33
|
removeLegacyRegistry,
|
|
48
34
|
removeRegistry,
|
|
49
35
|
settleGone,
|
|
50
36
|
} from "./registry.ts";
|
|
37
|
+
import { createOwnership, removeSessionMarker } from "./session.ts";
|
|
51
38
|
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
39
|
+
type Job,
|
|
40
|
+
type JobRecord,
|
|
41
|
+
type JobStatus,
|
|
42
|
+
type KillSignal,
|
|
43
|
+
type LogResult,
|
|
44
|
+
type SendOptions,
|
|
45
|
+
type SendResult,
|
|
46
|
+
type StartOptions,
|
|
47
|
+
type WaitResult,
|
|
48
|
+
} from "./types.ts";
|
|
60
49
|
import { createUiController } from "./ui.ts";
|
|
61
|
-
import { createJobStore, toRecord } from "./store.ts";
|
|
62
|
-
import { createWaiters } from "./waiters.ts";
|
|
63
|
-
|
|
64
|
-
/** Characters of sanitized log sent to the model. */
|
|
65
|
-
const LOG_MODEL_CHARS = 16 * 1024;
|
|
66
|
-
/** Default `wait` timeout. */
|
|
67
|
-
const DEFAULT_WAIT_MS = 30_000;
|
|
68
|
-
|
|
69
|
-
export interface StartOptions {
|
|
70
|
-
command: string;
|
|
71
|
-
cwd?: string;
|
|
72
|
-
label?: string;
|
|
73
|
-
wake?: boolean;
|
|
74
|
-
detached?: boolean;
|
|
75
|
-
/** Give the child a stdin pipe, so `send` can write to it while it runs here. */
|
|
76
|
-
stdin?: boolean;
|
|
77
|
-
/** Auto-kill the job after this many milliseconds (SIGTERM, escalating to SIGKILL). */
|
|
78
|
-
timeoutMs?: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface LogResult {
|
|
82
|
-
job: Job;
|
|
83
|
-
/** Model-facing render of the tail, including status header/truncation note. */
|
|
84
|
-
text: string;
|
|
85
|
-
/** Sanitized tail lines, for the `/jobs` log pane. */
|
|
86
|
-
lines: string[];
|
|
87
|
-
truncated: boolean;
|
|
88
|
-
/** True when earlier log lines exist beyond the returned window. */
|
|
89
|
-
more: boolean;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface WaitResult {
|
|
93
|
-
job: Job;
|
|
94
|
-
timedOut: boolean;
|
|
95
|
-
cancelled: boolean;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface SendOptions {
|
|
99
|
-
/** Text written to the job's stdin. */
|
|
100
|
-
input?: string;
|
|
101
|
-
/** Append a newline to `input`; defaults to true. */
|
|
102
|
-
newline?: boolean;
|
|
103
|
-
/** End the job's stdin after writing, so the child sees EOF. */
|
|
104
|
-
eof?: boolean;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface SendResult {
|
|
108
|
-
job: Job;
|
|
109
|
-
/** Characters of `input` written, excluding any newline this call added. */
|
|
110
|
-
sent: number;
|
|
111
|
-
/** True when this call ended the job's stdin. */
|
|
112
|
-
closed: boolean;
|
|
113
|
-
}
|
|
50
|
+
import { createJobStore, type JobStore, toRecord } from "./store.ts";
|
|
51
|
+
import { createWaiters, waitFor } from "./waiters.ts";
|
|
114
52
|
|
|
115
53
|
export interface RuntimeOptions {
|
|
116
54
|
spawn?: SpawnFn;
|
|
117
|
-
liveness?:
|
|
55
|
+
liveness?: (pid: number) => boolean;
|
|
118
56
|
killTree?: KillTreeFn;
|
|
119
|
-
startToken?:
|
|
57
|
+
startToken?: (pid: number) => string | undefined;
|
|
120
58
|
now?: () => number;
|
|
121
59
|
/** Platform seam: whether this is Windows. Defaults to the running platform. */
|
|
122
60
|
isWindows?: () => boolean;
|
|
123
61
|
/** Fixed config, for tests; production loads it per cwd. */
|
|
124
62
|
config?: JobsConfig;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
interface Handle {
|
|
128
|
-
/** The job's log: stream, line split, rotation, and the repaint window. */
|
|
129
|
-
log: LogHandle;
|
|
130
|
-
killTimer?: ReturnType<typeof setTimeout>;
|
|
131
|
-
timeoutTimer?: ReturnType<typeof setTimeout>;
|
|
132
|
-
/** The child's stdin pipe, present only for a job started with `stdin: true`. */
|
|
133
|
-
stdin?: StdinSink | null;
|
|
134
|
-
/** True once stdin was ended, or the child closed its end. */
|
|
135
|
-
stdinClosed?: boolean;
|
|
63
|
+
/** How long to wait before a shutdown's SIGKILL; the grace, injectable so a test need not sleep. */
|
|
64
|
+
sleep?: (ms: number) => Promise<void>;
|
|
136
65
|
}
|
|
137
66
|
|
|
138
67
|
export interface JobsRuntime {
|
|
@@ -142,8 +71,8 @@ export interface JobsRuntime {
|
|
|
142
71
|
/** Effective working directory, honoring an active worktree root. */
|
|
143
72
|
effectiveCwd(ctx: ExtensionContext): string;
|
|
144
73
|
/** Load and reconcile the registry, then paint the UI. */
|
|
145
|
-
load(ctx: ExtensionContext): void
|
|
146
|
-
start(options: StartOptions, ctx: ExtensionContext): Job
|
|
74
|
+
load(ctx: ExtensionContext): Promise<void>;
|
|
75
|
+
start(options: StartOptions, ctx: ExtensionContext): Promise<Job>;
|
|
147
76
|
list(): JobRecord[];
|
|
148
77
|
get(id: string): Job | undefined;
|
|
149
78
|
logs(id: string, lines?: number): LogResult | undefined;
|
|
@@ -152,7 +81,7 @@ export interface JobsRuntime {
|
|
|
152
81
|
* gone, was started without `stdin: true`, or belongs to another session.
|
|
153
82
|
*/
|
|
154
83
|
send(id: string, options: SendOptions): SendResult;
|
|
155
|
-
kill(id: string, signal?: KillSignal): Job | undefined
|
|
84
|
+
kill(id: string, signal?: KillSignal): Promise<Job | undefined>;
|
|
156
85
|
wait(
|
|
157
86
|
id: string,
|
|
158
87
|
timeoutMs: number | undefined,
|
|
@@ -170,31 +99,60 @@ export interface JobsRuntime {
|
|
|
170
99
|
/** Kill session-owned jobs (unless detached), stop the clock, persist. */
|
|
171
100
|
shutdown(): Promise<void>;
|
|
172
101
|
/** Called when a `wake` job finishes; the index triggers one turn. */
|
|
173
|
-
onFinish?: (job:
|
|
102
|
+
onFinish?: (job: JobRecord) => void;
|
|
174
103
|
/**
|
|
175
|
-
* Called when any job reaches a terminal state,
|
|
176
|
-
*
|
|
177
|
-
* bus; the runtime itself never reaches for a listener.
|
|
104
|
+
* Called when any job reaches a terminal state, however it settled. The index bridges
|
|
105
|
+
* it to the event bus; the runtime itself never reaches for a listener.
|
|
178
106
|
*/
|
|
179
107
|
onComplete?: (job: JobRecord) => void;
|
|
180
108
|
}
|
|
181
109
|
|
|
182
110
|
export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The process seam, loaded when a session first loads the registry.
|
|
113
|
+
*
|
|
114
|
+
* Building the runtime is registration: it happens on every Pi start, and
|
|
115
|
+
* `lib/spawn.ts` pulls `node:child_process` with it. The load itself has to answer
|
|
116
|
+
* whether recorded pids are alive, so the seam arrives with the first `load`
|
|
117
|
+
* rather than with the factory — and a runtime whose pids are all its own never
|
|
118
|
+
* needs the platform's default at all.
|
|
119
|
+
*/
|
|
120
|
+
let seams: Promise<JobSeams> | undefined;
|
|
121
|
+
const jobSeams = (): Promise<JobSeams> => {
|
|
122
|
+
seams ??= Promise.all([import("../../lib/spawn.ts"), import("../../lib/lines.ts")]).then(([spawn, lines]) => ({
|
|
123
|
+
spawn: options.spawn ?? spawn.defaultSpawn,
|
|
124
|
+
liveness: options.liveness ?? spawn.isPidAlive,
|
|
125
|
+
killTree: options.killTree ?? spawn.defaultKillTree,
|
|
126
|
+
startToken: options.startToken ?? spawn.startTokenOf,
|
|
127
|
+
newSplitter: () => new lines.LineSplitter(),
|
|
128
|
+
}));
|
|
129
|
+
return seams;
|
|
130
|
+
};
|
|
131
|
+
/** The seam once it has loaded; every path that asks about a pid has awaited it. */
|
|
132
|
+
let syncSeams: JobSeams | undefined;
|
|
133
|
+
|
|
134
|
+
/** Load the seam when it is not here yet, so a pid question can be asked. */
|
|
135
|
+
const openSeam = async (): Promise<void> => {
|
|
136
|
+
syncSeams ??= await jobSeams();
|
|
137
|
+
};
|
|
138
|
+
|
|
187
139
|
const now = options.now ?? (() => Date.now());
|
|
140
|
+
const sleep = options.sleep ?? abortableSleep;
|
|
188
141
|
const isWindows = options.isWindows ?? (() => process.platform === "win32");
|
|
189
142
|
|
|
143
|
+
// The config is read by `load`, once the session has a cwd; until then the shipped
|
|
144
|
+
// defaults answer, and a test's fixed config overrides both.
|
|
145
|
+
let config: JobsConfig = options.config ?? DEFAULT_CONFIG;
|
|
146
|
+
let jobStore: JobStore | undefined;
|
|
147
|
+
const store = (): JobStore => {
|
|
148
|
+
jobStore ??= createJobStore();
|
|
149
|
+
return jobStore;
|
|
150
|
+
};
|
|
151
|
+
|
|
190
152
|
const jobs = new Map<string, Job>();
|
|
191
|
-
const handles = new Map<string, Handle>();
|
|
192
153
|
const waiters = createWaiters();
|
|
193
|
-
const store = createJobStore();
|
|
194
|
-
let config: JobsConfig = options.config ?? loadConfig(process.cwd());
|
|
195
154
|
let sessionId = "session";
|
|
196
155
|
let disposed = false;
|
|
197
|
-
let clock: (() => void) | undefined;
|
|
198
156
|
|
|
199
157
|
/** Jobs this session owns (a dead session's adopted records included). */
|
|
200
158
|
const ownJobs = (): Job[] => [...jobs.values()].filter((job) => job.sessionId === sessionId);
|
|
@@ -204,114 +162,78 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
204
162
|
|
|
205
163
|
const effectiveCwd = (ctx: ExtensionContext): string => resolveEffectiveCwd(ctx.cwd);
|
|
206
164
|
|
|
207
|
-
const persist = (): void => store.persist(jobs.values());
|
|
165
|
+
const persist = (): void => store().persist(jobs.values());
|
|
208
166
|
|
|
209
|
-
/**
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (path && existsSync(path)) unlinkSync(path);
|
|
214
|
-
// Both segments are the job's own log; drop the rotated one too.
|
|
215
|
-
const rotated = store.safeRotatedLogPath(job);
|
|
216
|
-
if (rotated && existsSync(rotated)) unlinkSync(rotated);
|
|
217
|
-
} catch {
|
|
218
|
-
// Log cleanup is best-effort.
|
|
219
|
-
}
|
|
167
|
+
/** A fresh line splitter for one job's log, loaded with the seam that starts it. */
|
|
168
|
+
const newSplitter = (): ReturnType<JobSeams["newSplitter"]> => {
|
|
169
|
+
if (syncSeams === undefined) throw new Error("the job process seam is not loaded yet.");
|
|
170
|
+
return syncSeams.newSplitter();
|
|
220
171
|
};
|
|
221
172
|
|
|
222
|
-
/**
|
|
223
|
-
const
|
|
224
|
-
try {
|
|
225
|
-
const path = store.safeStatusPath(job);
|
|
226
|
-
if (path && existsSync(path)) unlinkSync(path);
|
|
227
|
-
} catch {
|
|
228
|
-
// Status cleanup is best-effort.
|
|
229
|
-
}
|
|
230
|
-
};
|
|
173
|
+
/** The process seam once it has loaded; a pid question before then answers as it always did. */
|
|
174
|
+
const jobSeam = (): JobSeams | undefined => syncSeams;
|
|
231
175
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
removeStatus(job);
|
|
236
|
-
removeLog(job);
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
/** Refresh this session's liveness marker while it runs jobs. */
|
|
240
|
-
const touchMarker = (): void => {
|
|
241
|
-
if (disposed) return;
|
|
242
|
-
touchSessionMarker(store.directory(), sessionId, process.pid, now());
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
/** Whether the session that owns a job is still alive. */
|
|
246
|
-
const ownerAlive = (owner: string): boolean =>
|
|
247
|
-
owner === sessionId ||
|
|
248
|
-
isSessionAlive(readSessionMarker(store.directory(), owner), now(), config.sessionTtlMs, liveness);
|
|
176
|
+
/** The listeners the index arms on session start; `undefined` after shutdown. */
|
|
177
|
+
let onCompleteHandler: ((job: JobRecord) => void) | undefined;
|
|
178
|
+
let onFinishHandler: ((job: JobRecord) => void) | undefined;
|
|
249
179
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
};
|
|
180
|
+
/** Settled before its listener was armed — a load finishes first — and replayed by it. */
|
|
181
|
+
let heldCompletions: JobRecord[] = [];
|
|
182
|
+
let heldWakes: JobRecord[] = [];
|
|
254
183
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (disposed
|
|
258
|
-
|
|
184
|
+
/** Publish a terminal transition unless the runtime is shutting down or was reloaded. */
|
|
185
|
+
const announceComplete = (job: JobRecord): void => {
|
|
186
|
+
if (disposed) return;
|
|
187
|
+
if (onCompleteHandler === undefined) {
|
|
188
|
+
heldCompletions.push(toRecord(job));
|
|
259
189
|
return;
|
|
260
190
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// transition, so anything that clears extension statuses (a session reload,
|
|
267
|
-
// another UI path) would hide a still-running job's chip until the next
|
|
268
|
-
// transition. The clock is the only periodic hook while jobs run.
|
|
269
|
-
ui.setStatus();
|
|
270
|
-
});
|
|
191
|
+
try {
|
|
192
|
+
onCompleteHandler(toRecord(job));
|
|
193
|
+
} catch {
|
|
194
|
+
// A completion listener must not break the process bookkeeping.
|
|
195
|
+
}
|
|
271
196
|
};
|
|
272
197
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
198
|
+
/** Fire the one wake turn a finished `wake` job asked for, or hold it until one is armed. */
|
|
199
|
+
const wakeIfNeeded = (job: JobRecord): void => {
|
|
200
|
+
if (!job.wake || disposed) return;
|
|
201
|
+
if (onFinishHandler === undefined) {
|
|
202
|
+
heldWakes.push(toRecord(job));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
onFinishHandler(job);
|
|
207
|
+
} catch {
|
|
208
|
+
// Wake is best-effort.
|
|
209
|
+
}
|
|
278
210
|
};
|
|
279
211
|
|
|
280
212
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
213
|
+
* Announce one terminal transition: the completion, then the wake turn it asked for. Every
|
|
214
|
+
* way a record settles ends here — a close event, a pid observed gone, a peer's leftover
|
|
215
|
+
* reaped, an orphan killed at load — so no path announces half the pair.
|
|
284
216
|
*/
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return !current || current === job.startToken;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
/** Recover an exit code from a job's status file, guarded by the trusted path. */
|
|
292
|
-
const exitCodeFor = (job: JobRecord): number | undefined => readExitStatus(store.safeStatusPath(job));
|
|
293
|
-
|
|
294
|
-
/** A completion listener, published from the index; `undefined` after shutdown. */
|
|
295
|
-
let onCompleteHandler: ((job: JobRecord) => void) | undefined;
|
|
296
|
-
|
|
297
|
-
/** Publish a terminal transition unless the runtime is shutting down or was reloaded. */
|
|
298
|
-
const announceComplete = (job: Job): void => {
|
|
299
|
-
if (disposed) return;
|
|
300
|
-
try {
|
|
301
|
-
onCompleteHandler?.(toRecord(job));
|
|
302
|
-
} catch {
|
|
303
|
-
// A completion listener must not break the process bookkeeping.
|
|
304
|
-
}
|
|
217
|
+
const publishSettled = (job: JobRecord): void => {
|
|
218
|
+
announceComplete(job);
|
|
219
|
+
wakeIfNeeded(job);
|
|
305
220
|
};
|
|
306
221
|
|
|
307
222
|
/** Move a record to a settled status, resolving any waiters and announcing it. */
|
|
308
|
-
const settle = (
|
|
223
|
+
const settle = (
|
|
224
|
+
job: JobRecord,
|
|
225
|
+
patch: { status: JobStatus; exitCode?: number | null; signal?: string | null },
|
|
226
|
+
): void => {
|
|
227
|
+
// A record this session settles is this session's now: the only peer record the
|
|
228
|
+
// table settles is one whose owner is gone, and until it is adopted its completion
|
|
229
|
+
// is reportable by nobody.
|
|
230
|
+
job.sessionId = sessionId;
|
|
309
231
|
job.status = patch.status;
|
|
310
232
|
job.exitCode = patch.exitCode ?? null;
|
|
311
233
|
job.signal = patch.signal ?? null;
|
|
312
234
|
job.finishedAt = now();
|
|
313
235
|
waiters.resolve(job.id);
|
|
314
|
-
|
|
236
|
+
publishSettled(job);
|
|
315
237
|
};
|
|
316
238
|
|
|
317
239
|
/**
|
|
@@ -322,54 +244,20 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
322
244
|
* every clock tick would be a notification storm.
|
|
323
245
|
*/
|
|
324
246
|
const settleGoneJob = (job: Job): boolean => {
|
|
325
|
-
Object.assign(
|
|
247
|
+
Object.assign(
|
|
248
|
+
job,
|
|
249
|
+
settleGone(job, {
|
|
250
|
+
isAlive: children.alive,
|
|
251
|
+
tokenMatches: children.owns,
|
|
252
|
+
readExitCode: children.exitCode,
|
|
253
|
+
now: now(),
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
326
256
|
if (job.status === "running") return false;
|
|
327
|
-
|
|
257
|
+
publishSettled(job);
|
|
328
258
|
return true;
|
|
329
259
|
};
|
|
330
260
|
|
|
331
|
-
/** Poll reattached (unowned) jobs, which have no `close` event to observe. */
|
|
332
|
-
const pollExternal = (): void => {
|
|
333
|
-
touchMarker();
|
|
334
|
-
let changed = false;
|
|
335
|
-
for (const job of jobs.values()) {
|
|
336
|
-
if (job.status !== "running" || job.owned) continue;
|
|
337
|
-
const peer = job.sessionId !== sessionId;
|
|
338
|
-
const alive = peer ? ownerAlive(job.sessionId) : true;
|
|
339
|
-
// A live peer session owns this job; leave its state to that owner.
|
|
340
|
-
if (peer && alive) continue;
|
|
341
|
-
if (job.pid === null || !liveness(job.pid)) {
|
|
342
|
-
// A record with no pid cannot be judged gone; leave it alone rather than
|
|
343
|
-
// announcing (and waking) on every tick.
|
|
344
|
-
if (!settleGoneJob(job)) continue;
|
|
345
|
-
// A reattached `wake` job has no `close` event, so this is the only place
|
|
346
|
-
// its completion can trigger the one wake turn.
|
|
347
|
-
wakeIfNeeded(job);
|
|
348
|
-
changed = true;
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
// The owner is gone and the job is not detached: reap the abandoned
|
|
352
|
-
// process, guarding against pid reuse with the start token.
|
|
353
|
-
if (peer && !job.detached) {
|
|
354
|
-
if (tokenMatches(job)) {
|
|
355
|
-
try {
|
|
356
|
-
killTree(job.pid, "SIGTERM");
|
|
357
|
-
} catch {
|
|
358
|
-
// Already gone.
|
|
359
|
-
}
|
|
360
|
-
settle(job, { status: "killed", signal: "SIGTERM" });
|
|
361
|
-
} else {
|
|
362
|
-
settle(job, { status: "unknown" });
|
|
363
|
-
}
|
|
364
|
-
changed = true;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
if (!changed) return;
|
|
368
|
-
pruneFinished();
|
|
369
|
-
persist();
|
|
370
|
-
if (!disposed) ui.paint();
|
|
371
|
-
};
|
|
372
|
-
|
|
373
261
|
const pruneFinished = (): void => {
|
|
374
262
|
// Only this session's records are ours to drop; a live peer manages its own.
|
|
375
263
|
// Prefer dropping already-reported jobs; an unseen completion is only pruned
|
|
@@ -382,97 +270,111 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
382
270
|
const job = finished.shift();
|
|
383
271
|
if (!job) break;
|
|
384
272
|
jobs.delete(job.id);
|
|
385
|
-
removeArtifacts(job);
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
/** Remember the newest non-empty output line, which `status`/`wait` report. */
|
|
390
|
-
const recordLine = (job: Job, line: string): void => {
|
|
391
|
-
const clean = sanitizeUntrustedLine(line);
|
|
392
|
-
if (clean) job.lastLine = clean;
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
/** The per-job log sink: stream, line split, rotation, and the repaint window. */
|
|
396
|
-
const logWriter = createLogWriter({
|
|
397
|
-
now,
|
|
398
|
-
logPath: (job) => store.safeLogPath(job),
|
|
399
|
-
rotatedPath: (job) => store.safeRotatedLogPath(job),
|
|
400
|
-
maxBytes: () => config.maxLogBytes,
|
|
401
|
-
repaintMs: () => config.repaintMs,
|
|
402
|
-
onLine: recordLine,
|
|
403
|
-
onPaint: paint,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
/** Clear a handle's timers, close its log stream, and drop it. */
|
|
407
|
-
const disposeHandle = (id: string, options: { endStream: boolean }): void => {
|
|
408
|
-
const handle = handles.get(id);
|
|
409
|
-
if (!handle) return;
|
|
410
|
-
if (handle.killTimer) clearTimeout(handle.killTimer);
|
|
411
|
-
if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
|
|
412
|
-
logWriter.dispose(handle.log, options);
|
|
413
|
-
// Release the stdin pipe so a child waiting on input sees EOF instead of a
|
|
414
|
-
// half-open pipe nobody can write to.
|
|
415
|
-
if (handle.stdin && !handle.stdinClosed) {
|
|
416
|
-
try {
|
|
417
|
-
handle.stdin.end();
|
|
418
|
-
} catch {
|
|
419
|
-
// Already closed.
|
|
420
|
-
}
|
|
273
|
+
children.removeArtifacts(job);
|
|
421
274
|
}
|
|
422
|
-
handle.stdin = null;
|
|
423
|
-
handle.stdinClosed = true;
|
|
424
|
-
handles.delete(id);
|
|
425
|
-
};
|
|
426
|
-
|
|
427
|
-
const onChunk = (id: string, chunk: Buffer): void => {
|
|
428
|
-
const handle = handles.get(id);
|
|
429
|
-
const job = jobs.get(id);
|
|
430
|
-
if (!handle || !job) return;
|
|
431
|
-
logWriter.write(handle.log, job, chunk);
|
|
432
275
|
};
|
|
433
276
|
|
|
434
277
|
const finalize = (id: string, code: number | null, signal: string | null): void => {
|
|
435
|
-
const handle = handles.get(id);
|
|
436
278
|
const job = jobs.get(id);
|
|
437
|
-
if (!
|
|
438
|
-
// Commit
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (!handle.log.stream) {
|
|
442
|
-
const last = readLogTail(store.safeLogPath(job), 1).lines.at(-1);
|
|
443
|
-
if (last) job.lastLine = last;
|
|
444
|
-
}
|
|
445
|
-
disposeHandle(id, { endStream: true });
|
|
279
|
+
if (!children.get(id) || !job || job.status !== "running") return;
|
|
280
|
+
// Commit the child's buffered output, then let its handle go.
|
|
281
|
+
children.flush(job);
|
|
282
|
+
children.dispose(id, { endStream: true });
|
|
446
283
|
|
|
447
284
|
const status: JobStatus = signal ? "killed" : code === 0 ? "exited" : "failed";
|
|
448
285
|
settle(job, { status, exitCode: signal ? null : code, signal });
|
|
449
286
|
// The close event already carried the code, so the status file is redundant.
|
|
450
|
-
removeStatus(job);
|
|
287
|
+
children.removeStatus(job);
|
|
451
288
|
|
|
452
289
|
pruneFinished();
|
|
453
290
|
persist();
|
|
454
|
-
paint();
|
|
291
|
+
clock.paint();
|
|
455
292
|
if (!disposed && status === "failed") ui.notifyFailure(job);
|
|
456
|
-
if (job.wake && !disposed) {
|
|
457
|
-
try {
|
|
458
|
-
onFinishHandler?.(job);
|
|
459
|
-
} catch {
|
|
460
|
-
// Wake is best-effort.
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
293
|
};
|
|
464
294
|
|
|
465
|
-
|
|
295
|
+
/**
|
|
296
|
+
* A job's process and its output, over `lib/child.ts`'s supervisor.
|
|
297
|
+
*
|
|
298
|
+
* The record's questions — is this pid still ours, what did it exit with, what
|
|
299
|
+
* does this log say — stay in this module; the handle, the log stream, stdin and
|
|
300
|
+
* the escalation are the child's.
|
|
301
|
+
*/
|
|
302
|
+
const children = createJobChildren({
|
|
303
|
+
seams: jobSeam,
|
|
304
|
+
store,
|
|
305
|
+
config: () => config,
|
|
306
|
+
now,
|
|
307
|
+
newSplitter,
|
|
308
|
+
table: {
|
|
309
|
+
record: (id) => jobs.get(id),
|
|
310
|
+
ids: () => jobs.keys(),
|
|
311
|
+
insert: (job) => jobs.set(job.id, job),
|
|
312
|
+
settle,
|
|
313
|
+
sessionId: () => sessionId,
|
|
314
|
+
changed: () => {
|
|
315
|
+
persist();
|
|
316
|
+
clock.paint();
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
onSettled: (id, outcome) => {
|
|
320
|
+
if (outcome.kind === "closed") finalize(id, outcome.code, outcome.signal);
|
|
321
|
+
else finalize(id, 1, null);
|
|
322
|
+
},
|
|
323
|
+
onPaint: () => clock.paint(),
|
|
324
|
+
});
|
|
466
325
|
|
|
467
|
-
/**
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Who owns a job: `live`, `quiet`, or `gone`, and this session's own marker. `quiet` is
|
|
328
|
+
* not dead — the heartbeat only ticks while a job runs, so a peer that has finished its
|
|
329
|
+
* work stops writing one, and reading that as death adopted a live session's records.
|
|
330
|
+
*/
|
|
331
|
+
const ownership = createOwnership({
|
|
332
|
+
directory: () => store().directory(),
|
|
333
|
+
sessionId: () => sessionId,
|
|
334
|
+
now,
|
|
335
|
+
ttlMs: () => config.sessionTtlMs,
|
|
336
|
+
alive: (pid) => children.alive(pid),
|
|
337
|
+
startToken: (pid) => children.startToken(pid),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/** Poll the jobs this session reattached, which have no `close` event to observe. */
|
|
341
|
+
const pollExternal = createExternalSweep({
|
|
342
|
+
records: () => jobs.values(),
|
|
343
|
+
sessionId: () => sessionId,
|
|
344
|
+
ownerPresent: (owner) => ownership.state(owner) !== "gone",
|
|
345
|
+
alive: (pid) => children.alive(pid),
|
|
346
|
+
owns: (job) => children.owns(job),
|
|
347
|
+
settleGone: settleGoneJob,
|
|
348
|
+
settle,
|
|
349
|
+
signal: (pid) => children.signal({ pid }, "SIGTERM"),
|
|
350
|
+
touchMarker: () => ownership.touch(),
|
|
351
|
+
changed: () => {
|
|
352
|
+
pruneFinished();
|
|
353
|
+
persist();
|
|
354
|
+
if (!disposed) ui.paint();
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The repaint clock: the only periodic hook the runtime arms.
|
|
360
|
+
*
|
|
361
|
+
* Its tick touches this session's liveness marker, polls the jobs it
|
|
362
|
+
* reattached, and re-asserts the chip — which is otherwise only set on a job
|
|
363
|
+
* transition, so anything that clears extension statuses (a session reload,
|
|
364
|
+
* another UI path) would hide a still-running job's chip.
|
|
365
|
+
*/
|
|
366
|
+
const clock = createRepaintClock({
|
|
367
|
+
repaintMs: () => config.repaintMs,
|
|
368
|
+
running: () => [...jobs.values()].some((job) => job.status === "running"),
|
|
369
|
+
disposed: () => disposed,
|
|
370
|
+
touchMarker: () => ownership.touch(),
|
|
371
|
+
tick: () => {
|
|
372
|
+
ownership.touch();
|
|
373
|
+
pollExternal();
|
|
374
|
+
ui.setStatus();
|
|
375
|
+
},
|
|
376
|
+
repaint: () => ui.paint(),
|
|
377
|
+
});
|
|
476
378
|
|
|
477
379
|
const runtime: JobsRuntime = {
|
|
478
380
|
get config() {
|
|
@@ -486,9 +388,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
486
388
|
start,
|
|
487
389
|
list: () => [...jobs.values()].sort(compareJobs).map(toRecord),
|
|
488
390
|
get: (id) => jobs.get(id),
|
|
489
|
-
logs,
|
|
490
|
-
send,
|
|
491
|
-
kill,
|
|
391
|
+
logs: (id, lines) => children.logs(id, lines),
|
|
392
|
+
send: (id, options) => children.send(id, options),
|
|
393
|
+
kill: (id, signal) => children.kill(id, signal),
|
|
492
394
|
wait,
|
|
493
395
|
clear,
|
|
494
396
|
pendingCompletions,
|
|
@@ -499,23 +401,39 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
499
401
|
get onFinish() {
|
|
500
402
|
return onFinishHandler;
|
|
501
403
|
},
|
|
502
|
-
|
|
404
|
+
// A load settles its orphans before `session_start` arms this listener, so what it
|
|
405
|
+
// owed is replayed here rather than announced into nothing.
|
|
406
|
+
set onFinish(handler: ((job: JobRecord) => void) | undefined) {
|
|
503
407
|
onFinishHandler = handler;
|
|
408
|
+
if (handler === undefined) return;
|
|
409
|
+
const held = heldWakes;
|
|
410
|
+
heldWakes = [];
|
|
411
|
+
for (const job of held) wakeIfNeeded(job);
|
|
504
412
|
},
|
|
505
413
|
get onComplete() {
|
|
506
414
|
return onCompleteHandler;
|
|
507
415
|
},
|
|
508
416
|
set onComplete(handler: ((job: JobRecord) => void) | undefined) {
|
|
509
417
|
onCompleteHandler = handler;
|
|
418
|
+
if (handler === undefined) return;
|
|
419
|
+
const held = heldCompletions;
|
|
420
|
+
heldCompletions = [];
|
|
421
|
+
for (const job of held) announceComplete(job);
|
|
510
422
|
},
|
|
511
423
|
};
|
|
512
424
|
|
|
513
|
-
function load(ctx: ExtensionContext): void {
|
|
425
|
+
async function load(ctx: ExtensionContext): Promise<void> {
|
|
426
|
+
// The seam comes with the first load: this is the first path that asks whether a
|
|
427
|
+
// recorded pid is still alive, and the only one that needs `child_process`.
|
|
428
|
+
await openSeam();
|
|
514
429
|
// A reload or session replacement can reuse this runtime after `shutdown`,
|
|
515
430
|
// so reset all session state before re-reading the registry.
|
|
516
431
|
disposed = false;
|
|
517
|
-
|
|
518
|
-
|
|
432
|
+
// Anything the last session settled unannounced belongs to its table, not this one.
|
|
433
|
+
heldCompletions = [];
|
|
434
|
+
heldWakes = [];
|
|
435
|
+
clock.stop();
|
|
436
|
+
children.disposeAll();
|
|
519
437
|
waiters.resolveAll();
|
|
520
438
|
|
|
521
439
|
// The durable registry is keyed by the session's own cwd, not the active
|
|
@@ -523,319 +441,59 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
523
441
|
// out from under the records already collected here. Jobs still run in the
|
|
524
442
|
// effective root (`start` uses `effectiveCwd`).
|
|
525
443
|
config = options.config ?? loadConfig(ctx.cwd);
|
|
526
|
-
store.setDirectory(registryDirFor(ctx.cwd, config.registryDir));
|
|
444
|
+
store().setDirectory(registryDirFor(ctx.cwd, config.registryDir));
|
|
527
445
|
sessionId = ctx.sessionManager.getSessionId();
|
|
528
|
-
store.setSession(sessionId);
|
|
446
|
+
store().setSession(sessionId);
|
|
529
447
|
ui.attach(ctx);
|
|
530
448
|
|
|
531
449
|
// Announce this session, and drop markers for sessions that are gone.
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const { files, counter } = loadRegistries(store.directory());
|
|
536
|
-
store.setCounter(counter);
|
|
537
|
-
|
|
538
|
-
// Merge every session's records. A file whose owner is gone is adopted: its
|
|
539
|
-
// records will be rewritten under this session and its file deleted.
|
|
540
|
-
const merged = new Map<string, JobRecord>();
|
|
541
|
-
const deadSessions = new Set<string>();
|
|
542
|
-
for (const file of files) {
|
|
543
|
-
if (file.sessionId !== sessionId && !ownerAlive(file.sessionId)) deadSessions.add(file.sessionId);
|
|
544
|
-
for (const record of file.jobs) {
|
|
545
|
-
const existing = merged.get(record.id);
|
|
546
|
-
merged.set(record.id, existing ? mergeRecords(existing, record, sessionId) : record);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
450
|
+
ownership.touch();
|
|
451
|
+
ownership.prune();
|
|
549
452
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
453
|
+
const { files, counter } = loadRegistries(store().directory());
|
|
454
|
+
store().setCounter(counter);
|
|
455
|
+
const loaded = loadRecords(files, {
|
|
456
|
+
sessionId,
|
|
457
|
+
ownerState: ownership.state,
|
|
458
|
+
process: children,
|
|
459
|
+
now: now(),
|
|
557
460
|
});
|
|
558
461
|
|
|
559
|
-
for (const record of orphans) {
|
|
560
|
-
if (record.pid !== null) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
// The process may already be gone.
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
record.status = "killed";
|
|
568
|
-
record.signal = "SIGTERM";
|
|
569
|
-
record.finishedAt = now();
|
|
462
|
+
for (const record of loaded.orphans) {
|
|
463
|
+
if (record.pid !== null) children.signal({ pid: record.pid }, "SIGTERM");
|
|
464
|
+
// An orphan killed here is a terminal state like any other, so it is settled the
|
|
465
|
+
// same way — announced, and woken, once `session_start` arms the listeners.
|
|
466
|
+
settle(record, { status: "killed", signal: "SIGTERM" });
|
|
570
467
|
}
|
|
571
468
|
|
|
572
469
|
jobs.clear();
|
|
573
|
-
for (const record of
|
|
574
|
-
const adopted = deadSessions.has(record.sessionId) ? { ...record, sessionId } : record;
|
|
470
|
+
for (const record of loaded.records) {
|
|
471
|
+
const adopted = loaded.deadSessions.has(record.sessionId) ? { ...record, sessionId } : record;
|
|
575
472
|
jobs.set(adopted.id, { ...adopted, owned: false });
|
|
576
473
|
}
|
|
577
474
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
removeLegacyRegistry(store.directory());
|
|
475
|
+
for (const dead of loaded.deadSessions) removeRegistry(store().directory(), dead);
|
|
476
|
+
removeLegacyRegistry(store().directory());
|
|
581
477
|
// Ids now committed to a registry no longer need their reservation.
|
|
582
|
-
store.pruneReservations(jobs.keys());
|
|
478
|
+
store().pruneReservations(jobs.keys());
|
|
583
479
|
|
|
584
480
|
pruneFinished();
|
|
585
481
|
persist();
|
|
586
|
-
paint();
|
|
482
|
+
clock.paint();
|
|
587
483
|
}
|
|
588
484
|
|
|
589
|
-
function start(startOptions: StartOptions, ctx: ExtensionContext): Job {
|
|
485
|
+
async function start(startOptions: StartOptions, ctx: ExtensionContext): Promise<Job> {
|
|
590
486
|
if (!config.enabled) throw new Error("jobs are disabled.");
|
|
591
487
|
ui.attach(ctx);
|
|
592
|
-
const wantsStdin = startOptions.stdin === true;
|
|
593
|
-
// Windows has no process group to leave and no shell `EXIT` trap to record an
|
|
594
|
-
// exit code, so a job there cannot outlive the session: refusing an explicit
|
|
595
|
-
// detach is the honest answer, and it happens before any id is reserved.
|
|
596
|
-
// `detachedByDefault` stays inert instead of failing every start with a config
|
|
597
|
-
// the user set on another machine; `doctor` names that.
|
|
598
|
-
const windows = isWindows();
|
|
599
|
-
if (windows && startOptions.detached === true) {
|
|
600
|
-
throw new Error(
|
|
601
|
-
"A detached job cannot outlive the session on Windows: there is no process group to leave " +
|
|
602
|
-
"and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
|
|
603
|
-
"run it under WSL, or move the work into a `subagent`.",
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
const detached = windows ? false : (startOptions.detached ?? config.detachedByDefault);
|
|
607
|
-
// A detached job outlives this process, so a stdin pipe owned by this session
|
|
608
|
-
// would hand it EOF the moment the session ends; refuse instead of silently
|
|
609
|
-
// dropping the pipe (or the detach).
|
|
610
|
-
if (wantsStdin && detached) {
|
|
611
|
-
throw new Error("a detached job cannot take stdin; start it with `detached: false`.");
|
|
612
|
-
}
|
|
613
488
|
const cwd = startOptions.cwd ? startOptions.cwd : effectiveCwd(ctx);
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// `store.nextId` folds in the on-disk counters and ids from every session,
|
|
618
|
-
// so a peer that advanced the shared counter cannot hand us a live job's id.
|
|
619
|
-
const id = store.nextId(jobs.keys());
|
|
620
|
-
const logPath = store.logPath(id);
|
|
621
|
-
// Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
|
|
622
|
-
// job points its own output at the log so it keeps logging after this process
|
|
623
|
-
// ends (which on Windows is never: `detached` is false there).
|
|
624
|
-
const statusPath = windows ? null : store.statusPath(id);
|
|
625
|
-
const detachOutput = detached;
|
|
626
|
-
|
|
627
|
-
// Wrap the command so a POSIX shell records its exit status; the path travels
|
|
628
|
-
// in the environment so the shell never has to quote it.
|
|
629
|
-
const program = withExitTrap(startOptions.command, windows, detachOutput);
|
|
630
|
-
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
631
|
-
if (statusPath) env[STATUS_ENV] = statusPath;
|
|
632
|
-
if (detachOutput) env[LOG_ENV] = logPath;
|
|
633
|
-
let process_: SpawnedProcess;
|
|
634
|
-
try {
|
|
635
|
-
process_ = spawn(program, { cwd, env, detachOutput, stdin: wantsStdin });
|
|
636
|
-
} catch (error) {
|
|
637
|
-
// A failed spawn must not leave the reserved id behind.
|
|
638
|
-
store.releaseId(id);
|
|
639
|
-
throw error;
|
|
640
|
-
}
|
|
641
|
-
const pid = process_.pid ?? null;
|
|
642
|
-
if (detachOutput) process_.unref?.();
|
|
643
|
-
// Create the file eagerly so `logs` works before the first write, then
|
|
644
|
-
// append through a stream. A detached job's shell writes the file directly.
|
|
645
|
-
try {
|
|
646
|
-
writeFileSync(logPath, "", { flag: "a" });
|
|
647
|
-
} catch {
|
|
648
|
-
// A missing directory was already handled by mkdirSync above.
|
|
649
|
-
}
|
|
650
|
-
const job: Job = {
|
|
651
|
-
id,
|
|
652
|
-
label: sanitizeUntrustedLine((startOptions.label ?? "").trim()) || clipLabel(startOptions.command),
|
|
653
|
-
command: startOptions.command,
|
|
654
|
-
cwd,
|
|
655
|
-
pid,
|
|
656
|
-
status: "running",
|
|
657
|
-
exitCode: null,
|
|
658
|
-
signal: null,
|
|
659
|
-
startedAt: now(),
|
|
660
|
-
finishedAt: null,
|
|
661
|
-
logPath,
|
|
662
|
-
statusPath,
|
|
663
|
-
detached,
|
|
664
|
-
wake: startOptions.wake ?? config.wakeOnFinish,
|
|
665
|
-
stdin: wantsStdin,
|
|
666
|
-
sessionId,
|
|
667
|
-
seen: false,
|
|
668
|
-
lastLine: "",
|
|
669
|
-
startToken: pid !== null ? (startToken(pid) ?? null) : null,
|
|
670
|
-
owned: true,
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
const handle: Handle = {
|
|
674
|
-
log: logWriter.create(detachOutput ? undefined : logPath),
|
|
675
|
-
};
|
|
676
|
-
if (wantsStdin) {
|
|
677
|
-
const sink = process_.stdin ?? null;
|
|
678
|
-
handle.stdin = sink;
|
|
679
|
-
handle.stdinClosed = sink === null;
|
|
680
|
-
// A child that exits can emit `error`/`close` on the pipe; record it so a
|
|
681
|
-
// later `send` reports the truth instead of throwing an unhandled stream error.
|
|
682
|
-
const markClosed = (): void => {
|
|
683
|
-
const current = handles.get(id);
|
|
684
|
-
if (current) current.stdinClosed = true;
|
|
685
|
-
};
|
|
686
|
-
sink?.on("error", markClosed);
|
|
687
|
-
sink?.on("close", markClosed);
|
|
688
|
-
}
|
|
689
|
-
handles.set(id, handle);
|
|
690
|
-
jobs.set(id, job);
|
|
691
|
-
|
|
692
|
-
if (startOptions.timeoutMs !== undefined) {
|
|
693
|
-
handle.timeoutTimer = setTimeout(() => {
|
|
694
|
-
if (jobs.get(id)?.status === "running") kill(id, "SIGTERM");
|
|
695
|
-
}, startOptions.timeoutMs);
|
|
696
|
-
handle.timeoutTimer.unref?.();
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
process_.stdout?.on("data", (chunk: Buffer) => onChunk(id, chunk));
|
|
700
|
-
process_.stderr?.on("data", (chunk: Buffer) => onChunk(id, chunk));
|
|
701
|
-
process_.on("close", (code, signal) => finalize(id, code, signal));
|
|
702
|
-
process_.on("error", () => finalize(id, 1, null));
|
|
703
|
-
|
|
489
|
+
// A start before `load` still needs the seam; load it rather than refusing.
|
|
490
|
+
await openSeam();
|
|
491
|
+
const job = children.start(startOptions, { cwd, windows: isWindows() });
|
|
704
492
|
persist();
|
|
705
|
-
paint();
|
|
706
|
-
return job;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function logs(id: string, lines = config.maxLogLines): LogResult | undefined {
|
|
710
|
-
const job = jobs.get(id);
|
|
711
|
-
if (!job) return undefined;
|
|
712
|
-
const current = readLogTail(store.safeLogPath(job), lines);
|
|
713
|
-
let all = current.lines;
|
|
714
|
-
let more = current.more;
|
|
715
|
-
let truncated = current.truncated;
|
|
716
|
-
// Keep `logs` continuous across a rotation: fill the request from the
|
|
717
|
-
// previous segment when the live one holds fewer lines.
|
|
718
|
-
if (all.length < lines) {
|
|
719
|
-
const previous = readLogTail(store.safeRotatedLogPath(job), lines - all.length);
|
|
720
|
-
if (previous.lines.length > 0) {
|
|
721
|
-
all = [...previous.lines, ...all];
|
|
722
|
-
more = more || previous.more;
|
|
723
|
-
truncated = truncated || previous.truncated;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
const last = all.at(-1);
|
|
727
|
-
if (last) job.lastLine = last;
|
|
728
|
-
const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
|
|
729
|
-
// A rotation that outran its drain drops the oldest queued chunks; say so once
|
|
730
|
-
// rather than letting the gap look like a program that skipped output.
|
|
731
|
-
const dropped = handles.get(id)?.log.droppedBytes ?? 0;
|
|
732
|
-
const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
|
|
733
|
-
return {
|
|
734
|
-
job,
|
|
735
|
-
text,
|
|
736
|
-
lines: all,
|
|
737
|
-
truncated: formatted.truncated || truncated,
|
|
738
|
-
more,
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function kill(id: string, signal: KillSignal = "SIGTERM"): Job | undefined {
|
|
743
|
-
const job = jobs.get(id);
|
|
744
|
-
if (!job) return undefined;
|
|
745
|
-
if (job.status !== "running") return job;
|
|
746
|
-
const pid = job.pid;
|
|
747
|
-
if (pid === null) {
|
|
748
|
-
settle(job, { status: "unknown" });
|
|
749
|
-
persist();
|
|
750
|
-
paint();
|
|
751
|
-
return job;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// A reattached pid may have been reused; if the start token disagrees, stop
|
|
755
|
-
// rather than signal an unrelated process.
|
|
756
|
-
if (!tokenMatches(job)) {
|
|
757
|
-
settle(job, { status: "unknown" });
|
|
758
|
-
persist();
|
|
759
|
-
paint();
|
|
760
|
-
return job;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Adopt a peer's job so the outcome persists in our file; the peer's stale
|
|
764
|
-
// file is dropped on a later load.
|
|
765
|
-
job.sessionId = sessionId;
|
|
766
|
-
|
|
767
|
-
try {
|
|
768
|
-
killTree(pid, signal, signal === "SIGKILL");
|
|
769
|
-
} catch {
|
|
770
|
-
// Best-effort; liveness below decides the final state.
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const handle = handles.get(id);
|
|
774
|
-
if (handle) {
|
|
775
|
-
if (signal !== "SIGKILL") {
|
|
776
|
-
if (handle.killTimer) clearTimeout(handle.killTimer);
|
|
777
|
-
handle.killTimer = setTimeout(() => {
|
|
778
|
-
if (jobs.get(id)?.status === "running") {
|
|
779
|
-
try {
|
|
780
|
-
killTree(pid, "SIGKILL", true);
|
|
781
|
-
} catch {
|
|
782
|
-
// Already gone.
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}, config.killGraceMs);
|
|
786
|
-
handle.killTimer.unref?.();
|
|
787
|
-
}
|
|
788
|
-
} else {
|
|
789
|
-
// Reattached/external Job: no `close` event will arrive, so reconcile
|
|
790
|
-
// after the grace period instead of waiting forever.
|
|
791
|
-
setTimeout(() => {
|
|
792
|
-
const current = jobs.get(id);
|
|
793
|
-
if (current?.status !== "running") return;
|
|
794
|
-
if (!liveness(pid)) {
|
|
795
|
-
settle(current, { status: "killed", signal });
|
|
796
|
-
} else {
|
|
797
|
-
try {
|
|
798
|
-
killTree(pid, "SIGKILL", true);
|
|
799
|
-
} catch {
|
|
800
|
-
// Best-effort.
|
|
801
|
-
}
|
|
802
|
-
settle(current, { status: "killed", signal: "SIGKILL" });
|
|
803
|
-
}
|
|
804
|
-
persist();
|
|
805
|
-
paint();
|
|
806
|
-
}, config.killGraceMs).unref?.();
|
|
807
|
-
}
|
|
493
|
+
clock.paint();
|
|
808
494
|
return job;
|
|
809
495
|
}
|
|
810
496
|
|
|
811
|
-
function send(id: string, options: SendOptions): SendResult {
|
|
812
|
-
const job = jobs.get(id);
|
|
813
|
-
if (!job) throw new Error(`no job "${id}".`);
|
|
814
|
-
if (job.status !== "running") throw new Error(`job ${id} is ${job.status}; stdin is closed.`);
|
|
815
|
-
if (!job.stdin) throw new Error(`job ${id} was not started with stdin; restart it with \`stdin: true\`.`);
|
|
816
|
-
const handle = handles.get(id);
|
|
817
|
-
// A pipe this session opened but that is closed is a different fact from one
|
|
818
|
-
// that was never ours, so state it first.
|
|
819
|
-
if (handle?.stdinClosed) throw new Error(`job ${id} closed its stdin.`);
|
|
820
|
-
if (!handle?.stdin) throw new Error(`job ${id}'s stdin belongs to the session that started it.`);
|
|
821
|
-
|
|
822
|
-
const input = options.input ?? "";
|
|
823
|
-
const payload = input && options.newline !== false ? `${input}\n` : input;
|
|
824
|
-
try {
|
|
825
|
-
if (payload) handle.stdin.write(payload);
|
|
826
|
-
if (options.eof) {
|
|
827
|
-
handle.stdin.end();
|
|
828
|
-
handle.stdinClosed = true;
|
|
829
|
-
}
|
|
830
|
-
} catch {
|
|
831
|
-
// The child closed its end; record it so the next `send` says so.
|
|
832
|
-
handle.stdinClosed = true;
|
|
833
|
-
handle.stdin = null;
|
|
834
|
-
throw new Error(`job ${id} closed its stdin.`);
|
|
835
|
-
}
|
|
836
|
-
return { job, sent: input.length, closed: options.eof === true };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
497
|
function wait(
|
|
840
498
|
id: string,
|
|
841
499
|
timeoutMs: number | undefined,
|
|
@@ -848,26 +506,12 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
848
506
|
return Promise.resolve({ job, timedOut: false, cancelled: false });
|
|
849
507
|
}
|
|
850
508
|
onUpdate?.();
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
settled = true;
|
|
858
|
-
if (timer) clearTimeout(timer);
|
|
859
|
-
signal?.removeEventListener("abort", onAbort);
|
|
860
|
-
remove();
|
|
861
|
-
resolve({ job: jobs.get(id) ?? job, timedOut, cancelled });
|
|
862
|
-
};
|
|
863
|
-
const resolveWaiter = (): void => finish(false, false);
|
|
864
|
-
const onAbort = (): void => finish(false, true);
|
|
865
|
-
const timer = setTimeout(() => finish(true, false), timeoutMs ?? DEFAULT_WAIT_MS);
|
|
866
|
-
|
|
867
|
-
remove = waiters.add(id, resolveWaiter);
|
|
868
|
-
if (signal?.aborted) onAbort();
|
|
869
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
870
|
-
});
|
|
509
|
+
// The record is read again at settle time, so the answer carries the job as it
|
|
510
|
+
// finished rather than as it was when the wait began.
|
|
511
|
+
return waitFor(waiters, id, { timeoutMs, signal }).then((outcome) => ({
|
|
512
|
+
job: jobs.get(id) ?? job,
|
|
513
|
+
...outcome,
|
|
514
|
+
}));
|
|
871
515
|
}
|
|
872
516
|
|
|
873
517
|
function clear(id: string | undefined, all: boolean): { cleared: number; refused?: string } {
|
|
@@ -880,9 +524,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
880
524
|
}
|
|
881
525
|
jobs.delete(id);
|
|
882
526
|
waiters.resolve(id);
|
|
883
|
-
removeArtifacts(job);
|
|
527
|
+
children.removeArtifacts(job);
|
|
884
528
|
persist();
|
|
885
|
-
paint();
|
|
529
|
+
clock.paint();
|
|
886
530
|
return { cleared: 1 };
|
|
887
531
|
}
|
|
888
532
|
|
|
@@ -893,23 +537,17 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
893
537
|
if (job.status === "running") {
|
|
894
538
|
// Without `all`, running jobs are left alone; with `all`, force-remove them.
|
|
895
539
|
if (!all) continue;
|
|
896
|
-
if (job.pid !== null) {
|
|
897
|
-
try {
|
|
898
|
-
killTree(job.pid, "SIGKILL", true);
|
|
899
|
-
} catch {
|
|
900
|
-
// Best-effort.
|
|
901
|
-
}
|
|
902
|
-
}
|
|
540
|
+
if (job.pid !== null) children.signal({ pid: job.pid }, "SIGKILL");
|
|
903
541
|
// Drop the handle too: the child will not deliver a `close` we want.
|
|
904
|
-
|
|
542
|
+
children.dispose(job.id, { endStream: true });
|
|
905
543
|
}
|
|
906
544
|
jobs.delete(job.id);
|
|
907
545
|
waiters.resolve(job.id);
|
|
908
|
-
removeArtifacts(job);
|
|
546
|
+
children.removeArtifacts(job);
|
|
909
547
|
cleared++;
|
|
910
548
|
}
|
|
911
549
|
persist();
|
|
912
|
-
paint();
|
|
550
|
+
clock.paint();
|
|
913
551
|
return { cleared };
|
|
914
552
|
}
|
|
915
553
|
|
|
@@ -935,21 +573,24 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
|
|
|
935
573
|
|
|
936
574
|
async function shutdown(): Promise<void> {
|
|
937
575
|
disposed = true;
|
|
938
|
-
|
|
576
|
+
clock.stop();
|
|
939
577
|
const running = [...jobs.values()].filter(
|
|
940
578
|
(job) => job.status === "running" && !job.detached && job.sessionId === sessionId,
|
|
941
579
|
);
|
|
942
|
-
for (const job of running) kill(job.id, "SIGTERM");
|
|
580
|
+
for (const job of running) await children.kill(job.id, "SIGTERM");
|
|
943
581
|
if (running.length > 0) {
|
|
944
|
-
|
|
582
|
+
// The grace is the same for every running job, handled or not: a reattached job has
|
|
583
|
+
// no child to watch, so a close-driven wait would SIGKILL it on the heels of its
|
|
584
|
+
// SIGTERM. The cap is the shared default, not a second literal.
|
|
585
|
+
await sleep(Math.min(config.killGraceMs, KILL_GRACE_MS));
|
|
945
586
|
}
|
|
946
587
|
for (const job of running) {
|
|
947
|
-
if (jobs.get(job.id)?.status === "running") kill(job.id, "SIGKILL");
|
|
588
|
+
if (jobs.get(job.id)?.status === "running") await children.kill(job.id, "SIGKILL");
|
|
948
589
|
}
|
|
949
|
-
|
|
590
|
+
children.disposeAll();
|
|
950
591
|
waiters.resolveAll();
|
|
951
592
|
persist();
|
|
952
|
-
removeSessionMarker(store.directory(), sessionId);
|
|
593
|
+
removeSessionMarker(store().directory(), sessionId);
|
|
953
594
|
ui.detach();
|
|
954
595
|
}
|
|
955
596
|
|