@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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A job's process and its output: the spawn policy, the handle, the log, stdin,
|
|
3
|
+
* the signal and its escalation, and the pid questions a durable record asks.
|
|
4
|
+
*
|
|
5
|
+
* The process side is `lib/child.ts`'s supervisor, which `watch` drives too, so
|
|
6
|
+
* the spawn, the merge of both output streams, the escalation timer, and the
|
|
7
|
+
* drain have one owner. What stays here is everything a *job* adds to that: the
|
|
8
|
+
* `EXIT`-trap program and its status file, a log the runtime can tail, a stdin
|
|
9
|
+
* pipe a later `send` writes to, and the start-time token that tells a reused pid
|
|
10
|
+
* from the one this record named.
|
|
11
|
+
*
|
|
12
|
+
* The table is the runtime's: this module asks for a record by id, hands back the
|
|
13
|
+
* one it started, and asks the runtime to settle one — it never holds the table.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { createChildSupervisor, type ChildOutcome, type ChildSlot, type ChildSupervisor } from "../../lib/child.ts";
|
|
18
|
+
import { sanitizeUntrustedLine } from "../../lib/format.ts";
|
|
19
|
+
import type { LineSplitter } from "../../lib/lines.ts";
|
|
20
|
+
import type { KillTreeFn, SpawnFn, StartTokenFn, StdinSink } from "../../lib/spawn.ts";
|
|
21
|
+
import { clipLabel } from "../../lib/tui/columns.ts";
|
|
22
|
+
import type { JobsConfig } from "./config.ts";
|
|
23
|
+
import { formatLogs } from "./format.ts";
|
|
24
|
+
import { createLogWriter, type LogHandle, type LogWriter } from "./log-writer.ts";
|
|
25
|
+
import { readLogTail } from "./logs.ts";
|
|
26
|
+
import { LOG_ENV, STATUS_ENV, readExitStatus, withExitTrap } from "./status.ts";
|
|
27
|
+
import type { JobStore } from "./store.ts";
|
|
28
|
+
import type {
|
|
29
|
+
Job,
|
|
30
|
+
JobRecord,
|
|
31
|
+
JobStatus,
|
|
32
|
+
KillSignal,
|
|
33
|
+
LogResult,
|
|
34
|
+
SendOptions,
|
|
35
|
+
SendResult,
|
|
36
|
+
StartOptions,
|
|
37
|
+
} from "./types.ts";
|
|
38
|
+
|
|
39
|
+
/** Characters of a job's log a model-facing tail may carry. */
|
|
40
|
+
const LOG_MODEL_CHARS = 16 * 1024;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The process seam a started job uses: the spawn, the three questions about a pid,
|
|
44
|
+
* and the log splitter. It arrives with `load`, because asking whether a recorded
|
|
45
|
+
* pid is alive is the first thing a session does.
|
|
46
|
+
*/
|
|
47
|
+
export interface JobSeams {
|
|
48
|
+
spawn: SpawnFn;
|
|
49
|
+
liveness: (pid: number) => boolean;
|
|
50
|
+
killTree: KillTreeFn;
|
|
51
|
+
startToken: StartTokenFn;
|
|
52
|
+
newSplitter: () => LineSplitter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A live job's process fields.
|
|
57
|
+
*
|
|
58
|
+
* The supervisor mutates `pid`/`child`/`killTimer` in place, so the handle and
|
|
59
|
+
* the module cannot disagree about whether the child is still live.
|
|
60
|
+
*/
|
|
61
|
+
export interface Handle extends ChildSlot {
|
|
62
|
+
/** The job's log: stream, line split, rotation, and the repaint window. */
|
|
63
|
+
log: LogHandle;
|
|
64
|
+
/** Escalates the job when it outlives its `timeoutMs`. */
|
|
65
|
+
timeoutTimer?: ReturnType<typeof setTimeout>;
|
|
66
|
+
/** The child's stdin pipe, present only for a job started with `stdin: true`. */
|
|
67
|
+
stdin?: StdinSink | null;
|
|
68
|
+
/** True once stdin was ended, or the child closed its end. */
|
|
69
|
+
stdinClosed?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a job's process asks of the runtime's table, and never answers itself. */
|
|
73
|
+
export interface JobTable {
|
|
74
|
+
/** One record from the table, by id. */
|
|
75
|
+
record(id: string): Job | undefined;
|
|
76
|
+
/** Every id the table holds, so a new one cannot collide with a record not yet pruned. */
|
|
77
|
+
ids(): Iterable<string>;
|
|
78
|
+
/** Put a record this module just started into the table. */
|
|
79
|
+
insert(job: Job): void;
|
|
80
|
+
/** Move a record to a settled status: waiters, listeners, and the event bus. */
|
|
81
|
+
settle(job: Job, patch: { status: JobStatus; exitCode?: number | null; signal?: string | null }): void;
|
|
82
|
+
/** This session's id, recorded as the owner of every job it starts or adopts. */
|
|
83
|
+
sessionId(): string;
|
|
84
|
+
/** Persist the table and repaint, after a record changed. */
|
|
85
|
+
changed(): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface JobChildrenDeps {
|
|
89
|
+
/**
|
|
90
|
+
* The process seam, once it has loaded: `lib/spawn.ts`'s spawn, tree-kill, liveness and
|
|
91
|
+
* start token. Undefined before `load`, where a pid question answers as it always did.
|
|
92
|
+
*/
|
|
93
|
+
seams: () => JobSeams | undefined;
|
|
94
|
+
/** The durable store, for a job's log, its rotated segment, and the id it is filed under. */
|
|
95
|
+
store: () => JobStore;
|
|
96
|
+
/** The live config, read per call so a reload lands. */
|
|
97
|
+
config: () => JobsConfig;
|
|
98
|
+
now: () => number;
|
|
99
|
+
/** A fresh line splitter, which arrives with the process seam. */
|
|
100
|
+
newSplitter: () => LineSplitter;
|
|
101
|
+
table: JobTable;
|
|
102
|
+
/** The child closed or failed to spawn; the runtime settles the record. */
|
|
103
|
+
onSettled: (id: string, outcome: ChildOutcome) => void;
|
|
104
|
+
/** Repaint, at most once per repaint window while output arrives. */
|
|
105
|
+
onPaint: () => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface JobChildren {
|
|
109
|
+
/** Spawn a job's process, record its handle, and answer the record to insert. */
|
|
110
|
+
start(options: StartOptions, placement: { cwd: string; windows: boolean }): Job;
|
|
111
|
+
/** SIGTERM a job and escalate to SIGKILL after the grace, or reconcile a reattached one. */
|
|
112
|
+
kill(id: string, signal?: KillSignal): Promise<Job | undefined>;
|
|
113
|
+
/** Whether a pid is still running, asked through the loaded seam. */
|
|
114
|
+
alive(pid: number): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Whether a persisted start-time token still matches the live pid.
|
|
117
|
+
* True when either side is unavailable, so a caller falls back to liveness alone.
|
|
118
|
+
*/
|
|
119
|
+
owns(job: JobRecord): boolean;
|
|
120
|
+
/** A pid's start time, when the platform can say; undefined before the seam loads. */
|
|
121
|
+
startToken(pid: number): string | undefined;
|
|
122
|
+
get(id: string): Handle | undefined;
|
|
123
|
+
/** Clear a handle's timers, close its log stream, and drop it. */
|
|
124
|
+
dispose(id: string, options: { endStream: boolean }): void;
|
|
125
|
+
/** Dispose every handle, for a session that is starting over. */
|
|
126
|
+
disposeAll(): void;
|
|
127
|
+
/** Signal a job's process, or a bare pid no handle holds (a reattached job). */
|
|
128
|
+
signal(slot: ChildSlot, signal: NodeJS.Signals): void;
|
|
129
|
+
/** Write to a running job's stdin, or throw the model-readable refusal. */
|
|
130
|
+
send(id: string, options: SendOptions): SendResult;
|
|
131
|
+
/** A job's log across both segments, formatted for the model. */
|
|
132
|
+
logs(id: string, lines?: number): LogResult | undefined;
|
|
133
|
+
/** Commit a settled job's buffered output and record its last line. */
|
|
134
|
+
flush(job: Job): void;
|
|
135
|
+
/** Recover an exit code from a job's status file, guarded by the trusted path. */
|
|
136
|
+
exitCode(job: JobRecord): number | undefined;
|
|
137
|
+
/** Delete a job's status file once its code has been observed, best-effort. */
|
|
138
|
+
removeStatus(job: JobRecord): void;
|
|
139
|
+
/** Delete a job's log file and any rotated segment, best-effort. */
|
|
140
|
+
removeLog(job: JobRecord): void;
|
|
141
|
+
/** Remove every durable artifact a job leaves behind, its id reservation included. */
|
|
142
|
+
removeArtifacts(job: JobRecord): void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createJobChildren(deps: JobChildrenDeps): JobChildren {
|
|
146
|
+
const handles = new Map<string, Handle>();
|
|
147
|
+
|
|
148
|
+
/** The seam a spawn or a signal needs; a caller that reaches here has awaited `load`. */
|
|
149
|
+
const requireSeams = (): JobSeams => {
|
|
150
|
+
const seams = deps.seams();
|
|
151
|
+
if (seams === undefined) throw new Error("the job process seam is not loaded yet.");
|
|
152
|
+
return seams;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** One supervisor per grace, so a reloaded config re-arms the escalation at its own speed. */
|
|
156
|
+
let supervisor: ChildSupervisor | undefined;
|
|
157
|
+
let supervisorGrace: number | undefined;
|
|
158
|
+
const supervisorFor = (): ChildSupervisor => {
|
|
159
|
+
const grace = deps.config().killGraceMs;
|
|
160
|
+
if (supervisor === undefined || supervisorGrace !== grace) {
|
|
161
|
+
const { spawn, killTree } = requireSeams();
|
|
162
|
+
supervisor = createChildSupervisor({ spawn, killTree, graceMs: grace });
|
|
163
|
+
supervisorGrace = grace;
|
|
164
|
+
}
|
|
165
|
+
return supervisor;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** A pid question before `load` answers `true`, which is what the runtime always said. */
|
|
169
|
+
const alive = (pid: number): boolean => deps.seams()?.liveness(pid) ?? true;
|
|
170
|
+
|
|
171
|
+
/** A mismatch means the pid was reused by another program, so it must not be signalled. */
|
|
172
|
+
const owns = (job: JobRecord): boolean => {
|
|
173
|
+
if (!job.startToken || job.pid === null) return true;
|
|
174
|
+
const current = deps.seams()?.startToken(job.pid);
|
|
175
|
+
return !current || current === job.startToken;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** A pid's start time, for the ownership rule's own marker. */
|
|
179
|
+
const startToken = (pid: number): string | undefined => deps.seams()?.startToken(pid);
|
|
180
|
+
|
|
181
|
+
/** Remember the newest non-empty output line, which `status`/`wait` report. */
|
|
182
|
+
const recordLine = (job: Job, line: string): void => {
|
|
183
|
+
const clean = sanitizeUntrustedLine(line);
|
|
184
|
+
if (clean) job.lastLine = clean;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The per-job log sink: stream, line split, rotation, and the repaint window.
|
|
189
|
+
*
|
|
190
|
+
* Built on first use, because the line splitter arrives with the process seam: a
|
|
191
|
+
* session that never starts a job never writes one.
|
|
192
|
+
*/
|
|
193
|
+
let sink: LogWriter | undefined;
|
|
194
|
+
const logWriter = (): LogWriter => {
|
|
195
|
+
sink ??= createLogWriter({
|
|
196
|
+
now: deps.now,
|
|
197
|
+
newSplitter: deps.newSplitter,
|
|
198
|
+
logPath: (job) => deps.store().safeLogPath(job),
|
|
199
|
+
rotatedPath: (job) => deps.store().safeRotatedLogPath(job),
|
|
200
|
+
maxBytes: () => deps.config().maxLogBytes,
|
|
201
|
+
repaintMs: () => deps.config().repaintMs,
|
|
202
|
+
onLine: recordLine,
|
|
203
|
+
onPaint: deps.onPaint,
|
|
204
|
+
});
|
|
205
|
+
return sink;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const writeChunk = (id: string, chunk: Buffer): void => {
|
|
209
|
+
const handle = handles.get(id);
|
|
210
|
+
const job = deps.table.record(id);
|
|
211
|
+
if (!handle || !job) return;
|
|
212
|
+
logWriter().write(handle.log, job, chunk);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const start = (options: StartOptions, placement: { cwd: string; windows: boolean }): Job => {
|
|
216
|
+
const { windows } = placement;
|
|
217
|
+
const config = deps.config();
|
|
218
|
+
const wantsStdin = options.stdin === true;
|
|
219
|
+
// Windows has no process group to leave and no shell `EXIT` trap to record an
|
|
220
|
+
// exit code, so a job there cannot outlive the session: refusing an explicit
|
|
221
|
+
// detach is the honest answer, and it happens before any id is reserved.
|
|
222
|
+
// `detachedByDefault` stays inert instead of failing every start with a config
|
|
223
|
+
// the user set on another machine; `doctor` names that.
|
|
224
|
+
if (windows && options.detached === true) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"A detached job cannot outlive the session on Windows: there is no process group to leave " +
|
|
227
|
+
"and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
|
|
228
|
+
"run it under WSL, or move the work into a `subagent`.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const detached = windows ? false : (options.detached ?? config.detachedByDefault);
|
|
232
|
+
// A detached job outlives this process, so a stdin pipe owned by this session
|
|
233
|
+
// would hand it EOF the moment the session ends; refuse instead of silently
|
|
234
|
+
// dropping the pipe (or the detach).
|
|
235
|
+
if (wantsStdin && detached) {
|
|
236
|
+
throw new Error("a detached job cannot take stdin; start it with `detached: false`.");
|
|
237
|
+
}
|
|
238
|
+
const store = deps.store();
|
|
239
|
+
// The registry directory must exist before `nextId`, which reserves the id
|
|
240
|
+
// with an `O_EXCL` lock file inside it.
|
|
241
|
+
mkdirSync(store.directory(), { recursive: true });
|
|
242
|
+
// `nextId` folds in the on-disk counters and ids from every session, so a peer
|
|
243
|
+
// that advanced the shared counter cannot hand us a live job's id — and the table's
|
|
244
|
+
// ids cover a record this session started and has not pruned yet.
|
|
245
|
+
const id = store.nextId(deps.table.ids());
|
|
246
|
+
const logPath = store.logPath(id);
|
|
247
|
+
// Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
|
|
248
|
+
// job points its own output at the log so it keeps logging after this process
|
|
249
|
+
// ends (which on Windows is never: `detached` is false there).
|
|
250
|
+
const statusPath = windows ? null : store.statusPath(id);
|
|
251
|
+
|
|
252
|
+
// Wrap the command so a POSIX shell records its exit status; the path travels
|
|
253
|
+
// in the environment so the shell never has to quote it.
|
|
254
|
+
const program = withExitTrap(options.command, windows, detached);
|
|
255
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
256
|
+
if (statusPath) env[STATUS_ENV] = statusPath;
|
|
257
|
+
if (detached) env[LOG_ENV] = logPath;
|
|
258
|
+
const handle: Handle = { log: logWriter().create(detached ? undefined : logPath) };
|
|
259
|
+
let error: string | undefined;
|
|
260
|
+
try {
|
|
261
|
+
error = supervisorFor().start(
|
|
262
|
+
handle,
|
|
263
|
+
program,
|
|
264
|
+
{ cwd: placement.cwd, env, detachOutput: detached, stdin: wantsStdin },
|
|
265
|
+
{
|
|
266
|
+
onData: (chunk) => writeChunk(id, chunk),
|
|
267
|
+
onSettled: (outcome) => deps.onSettled(id, outcome),
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
} catch (thrown) {
|
|
271
|
+
logWriter().dispose(handle.log, { endStream: true });
|
|
272
|
+
throw thrown;
|
|
273
|
+
}
|
|
274
|
+
if (error !== undefined) {
|
|
275
|
+
// A failed spawn must not leave the reserved id or a log stream behind.
|
|
276
|
+
logWriter().dispose(handle.log, { endStream: true });
|
|
277
|
+
store.releaseId(id);
|
|
278
|
+
throw new Error(error);
|
|
279
|
+
}
|
|
280
|
+
handles.set(id, handle);
|
|
281
|
+
if (detached) handle.child?.unref?.();
|
|
282
|
+
// Create the file eagerly so `logs` works before the first write, then append
|
|
283
|
+
// through the stream. A detached job's shell writes the file directly.
|
|
284
|
+
try {
|
|
285
|
+
writeFileSync(logPath, "", { flag: "a" });
|
|
286
|
+
} catch {
|
|
287
|
+
// An unwritable log leaves `logs` empty; the job still runs.
|
|
288
|
+
}
|
|
289
|
+
if (wantsStdin) {
|
|
290
|
+
const pipe = handle.child?.stdin ?? null;
|
|
291
|
+
handle.stdin = pipe;
|
|
292
|
+
handle.stdinClosed = pipe === null;
|
|
293
|
+
// A child that exits can emit `error`/`close` on the pipe; record it so a
|
|
294
|
+
// later `send` reports the truth instead of throwing an unhandled stream error.
|
|
295
|
+
const markClosed = (): void => {
|
|
296
|
+
const current = handles.get(id);
|
|
297
|
+
if (current) current.stdinClosed = true;
|
|
298
|
+
};
|
|
299
|
+
pipe?.on("error", markClosed);
|
|
300
|
+
pipe?.on("close", markClosed);
|
|
301
|
+
}
|
|
302
|
+
const pid = handle.pid ?? null;
|
|
303
|
+
const job: Job = {
|
|
304
|
+
id,
|
|
305
|
+
label: sanitizeUntrustedLine((options.label ?? "").trim()) || clipLabel(options.command),
|
|
306
|
+
command: options.command,
|
|
307
|
+
cwd: placement.cwd,
|
|
308
|
+
pid,
|
|
309
|
+
status: "running",
|
|
310
|
+
exitCode: null,
|
|
311
|
+
signal: null,
|
|
312
|
+
startedAt: deps.now(),
|
|
313
|
+
finishedAt: null,
|
|
314
|
+
logPath,
|
|
315
|
+
statusPath,
|
|
316
|
+
detached,
|
|
317
|
+
wake: options.wake ?? config.wakeOnFinish,
|
|
318
|
+
stdin: wantsStdin,
|
|
319
|
+
sessionId: deps.table.sessionId(),
|
|
320
|
+
seen: false,
|
|
321
|
+
lastLine: "",
|
|
322
|
+
startToken: pid !== null ? (requireSeams().startToken(pid) ?? null) : null,
|
|
323
|
+
owned: true,
|
|
324
|
+
};
|
|
325
|
+
deps.table.insert(job);
|
|
326
|
+
|
|
327
|
+
if (options.timeoutMs !== undefined) {
|
|
328
|
+
handle.timeoutTimer = setTimeout(() => {
|
|
329
|
+
if (deps.table.record(id)?.status === "running") void kill(id, "SIGTERM");
|
|
330
|
+
}, options.timeoutMs);
|
|
331
|
+
handle.timeoutTimer.unref?.();
|
|
332
|
+
}
|
|
333
|
+
return job;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const kill = async (id: string, signal: KillSignal = "SIGTERM"): Promise<Job | undefined> => {
|
|
337
|
+
const job = deps.table.record(id);
|
|
338
|
+
if (!job) return undefined;
|
|
339
|
+
if (job.status !== "running") return job;
|
|
340
|
+
const pid = job.pid;
|
|
341
|
+
if (pid === null) {
|
|
342
|
+
deps.table.settle(job, { status: "unknown" });
|
|
343
|
+
deps.table.changed();
|
|
344
|
+
return job;
|
|
345
|
+
}
|
|
346
|
+
// A reattached pid may have been reused; if the start token disagrees, stop
|
|
347
|
+
// rather than signal an unrelated process.
|
|
348
|
+
if (!owns(job)) {
|
|
349
|
+
deps.table.settle(job, { status: "unknown" });
|
|
350
|
+
deps.table.changed();
|
|
351
|
+
return job;
|
|
352
|
+
}
|
|
353
|
+
// Adopt a peer's job so the outcome persists in our file; the peer's stale
|
|
354
|
+
// file is dropped on a later load.
|
|
355
|
+
job.sessionId = deps.table.sessionId();
|
|
356
|
+
|
|
357
|
+
const handle = handles.get(id);
|
|
358
|
+
if (handle) {
|
|
359
|
+
// The supervisor owns the escalation: the signal the caller asked for now (SIGINT
|
|
360
|
+
// included), then SIGKILL at the configured grace unless the child closes first —
|
|
361
|
+
// and no escalation behind a SIGKILL, which is already final.
|
|
362
|
+
supervisorFor().terminate(handle, signal);
|
|
363
|
+
return job;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Reattached/external Job: no handle, so no `close` event will arrive either. Signal the
|
|
367
|
+
// pid and reconcile after the grace whatever the signal was — SIGKILL is final for a
|
|
368
|
+
// child we hold, not for one whose ending nothing reports.
|
|
369
|
+
supervisorFor().kill({ pid }, signal);
|
|
370
|
+
setTimeout(() => {
|
|
371
|
+
const current = deps.table.record(id);
|
|
372
|
+
if (current?.status !== "running") return;
|
|
373
|
+
if (!alive(pid)) {
|
|
374
|
+
deps.table.settle(current, { status: "killed", signal });
|
|
375
|
+
} else {
|
|
376
|
+
supervisorFor().kill({ pid }, "SIGKILL");
|
|
377
|
+
deps.table.settle(current, { status: "killed", signal: "SIGKILL" });
|
|
378
|
+
}
|
|
379
|
+
deps.table.changed();
|
|
380
|
+
}, deps.config().killGraceMs).unref?.();
|
|
381
|
+
return job;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const dispose = (id: string, options: { endStream: boolean }): void => {
|
|
385
|
+
const handle = handles.get(id);
|
|
386
|
+
if (!handle) return;
|
|
387
|
+
if (handle.killTimer) clearTimeout(handle.killTimer);
|
|
388
|
+
if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
|
|
389
|
+
logWriter().dispose(handle.log, options);
|
|
390
|
+
// Release the stdin pipe so a child waiting on input sees EOF instead of a
|
|
391
|
+
// half-open pipe nobody can write to.
|
|
392
|
+
if (handle.stdin && !handle.stdinClosed) {
|
|
393
|
+
try {
|
|
394
|
+
handle.stdin.end();
|
|
395
|
+
} catch {
|
|
396
|
+
// Already closed.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
handle.stdin = null;
|
|
400
|
+
handle.stdinClosed = true;
|
|
401
|
+
handles.delete(id);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/** Commit any buffered output, including a final line without a newline. */
|
|
405
|
+
const flush = (job: Job): void => {
|
|
406
|
+
const handle = handles.get(job.id);
|
|
407
|
+
if (!handle) return;
|
|
408
|
+
for (const line of logWriter().flush(handle.log)) recordLine(job, line);
|
|
409
|
+
// A detached job keeps no parent stream; read its last line from the log.
|
|
410
|
+
if (!handle.log.stream) {
|
|
411
|
+
const last = readLogTail(deps.store().safeLogPath(job), 1).lines.at(-1);
|
|
412
|
+
if (last) job.lastLine = last;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const logs = (id: string, lines?: number): LogResult | undefined => {
|
|
417
|
+
const job = deps.table.record(id);
|
|
418
|
+
if (!job) return undefined;
|
|
419
|
+
const count = lines ?? deps.config().maxLogLines;
|
|
420
|
+
const current = readLogTail(deps.store().safeLogPath(job), count);
|
|
421
|
+
let all = current.lines;
|
|
422
|
+
let more = current.more;
|
|
423
|
+
let truncated = current.truncated;
|
|
424
|
+
// Keep `logs` continuous across a rotation: fill the request from the
|
|
425
|
+
// previous segment when the live one holds fewer lines.
|
|
426
|
+
if (all.length < count) {
|
|
427
|
+
const previous = readLogTail(deps.store().safeRotatedLogPath(job), count - all.length);
|
|
428
|
+
if (previous.lines.length > 0) {
|
|
429
|
+
all = [...previous.lines, ...all];
|
|
430
|
+
more = more || previous.more;
|
|
431
|
+
truncated = truncated || previous.truncated;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const last = all.at(-1);
|
|
435
|
+
if (last) job.lastLine = last;
|
|
436
|
+
const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
|
|
437
|
+
// A rotation that outran its drain drops the oldest queued chunks; say so once
|
|
438
|
+
// rather than letting the gap look like a program that skipped output.
|
|
439
|
+
const dropped = handles.get(id)?.log.droppedBytes ?? 0;
|
|
440
|
+
const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
|
|
441
|
+
return {
|
|
442
|
+
job,
|
|
443
|
+
text,
|
|
444
|
+
lines: all,
|
|
445
|
+
truncated: formatted.truncated || truncated,
|
|
446
|
+
more,
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const send = (id: string, options: SendOptions): SendResult => {
|
|
451
|
+
const job = deps.table.record(id);
|
|
452
|
+
if (!job) throw new Error(`no job "${id}".`);
|
|
453
|
+
if (job.status !== "running") throw new Error(`job ${id} is ${job.status}; stdin is closed.`);
|
|
454
|
+
if (!job.stdin) throw new Error(`job ${id} was not started with stdin; restart it with \`stdin: true\`.`);
|
|
455
|
+
const handle = handles.get(id);
|
|
456
|
+
// A pipe this session opened but that is closed is a different fact from one
|
|
457
|
+
// that was never ours, so state it first.
|
|
458
|
+
if (handle?.stdinClosed) throw new Error(`job ${id} closed its stdin.`);
|
|
459
|
+
if (!handle?.stdin) throw new Error(`job ${id}'s stdin belongs to the session that started it.`);
|
|
460
|
+
|
|
461
|
+
const input = options.input ?? "";
|
|
462
|
+
const payload = input && options.newline !== false ? `${input}\n` : input;
|
|
463
|
+
try {
|
|
464
|
+
if (payload) handle.stdin.write(payload);
|
|
465
|
+
if (options.eof) {
|
|
466
|
+
handle.stdin.end();
|
|
467
|
+
handle.stdinClosed = true;
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
// The child closed its end; record it so the next `send` says so.
|
|
471
|
+
handle.stdinClosed = true;
|
|
472
|
+
handle.stdin = null;
|
|
473
|
+
throw new Error(`job ${id} closed its stdin.`);
|
|
474
|
+
}
|
|
475
|
+
return { job, sent: input.length, closed: options.eof === true };
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const exitCode = (job: JobRecord): number | undefined => readExitStatus(deps.store().safeStatusPath(job));
|
|
479
|
+
|
|
480
|
+
const removeStatus = (job: JobRecord): void => {
|
|
481
|
+
try {
|
|
482
|
+
const path = deps.store().safeStatusPath(job);
|
|
483
|
+
if (path && existsSync(path)) unlinkSync(path);
|
|
484
|
+
} catch {
|
|
485
|
+
// Status cleanup is best-effort.
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const removeLog = (job: JobRecord): void => {
|
|
490
|
+
try {
|
|
491
|
+
const path = deps.store().safeLogPath(job);
|
|
492
|
+
if (path && existsSync(path)) unlinkSync(path);
|
|
493
|
+
// Both segments are the job's own log; drop the rotated one too.
|
|
494
|
+
const rotated = deps.store().safeRotatedLogPath(job);
|
|
495
|
+
if (rotated && existsSync(rotated)) unlinkSync(rotated);
|
|
496
|
+
} catch {
|
|
497
|
+
// Log cleanup is best-effort.
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const removeArtifacts = (job: JobRecord): void => {
|
|
502
|
+
deps.store().releaseId(job.id);
|
|
503
|
+
removeStatus(job);
|
|
504
|
+
removeLog(job);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
start,
|
|
509
|
+
kill,
|
|
510
|
+
alive,
|
|
511
|
+
startToken,
|
|
512
|
+
owns,
|
|
513
|
+
get: (id) => handles.get(id),
|
|
514
|
+
dispose,
|
|
515
|
+
disposeAll: () => {
|
|
516
|
+
for (const id of [...handles.keys()]) dispose(id, { endStream: true });
|
|
517
|
+
},
|
|
518
|
+
signal: (slot, signal) => supervisorFor().kill(slot, signal),
|
|
519
|
+
send,
|
|
520
|
+
logs,
|
|
521
|
+
flush,
|
|
522
|
+
exitCode,
|
|
523
|
+
removeStatus,
|
|
524
|
+
removeLog,
|
|
525
|
+
removeArtifacts,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The job runtime's periodic work: the repaint clock, and the sweep that polls
|
|
3
|
+
* the jobs this session reattached.
|
|
4
|
+
*
|
|
5
|
+
* The clock is the only periodic hook the runtime arms. While a job runs it
|
|
6
|
+
* re-asserts the footer chip, touches this session's liveness marker, and lets
|
|
7
|
+
* the runtime poll the jobs it reattached; without it, anything that clears
|
|
8
|
+
* extension statuses (a session reload, another UI path) would hide a
|
|
9
|
+
* still-running job's chip until the next transition.
|
|
10
|
+
*
|
|
11
|
+
* Every fact it acts on is the caller's and arrives as a function: whether a job
|
|
12
|
+
* is running, whether the runtime was disposed, what a tick does, and what a
|
|
13
|
+
* repaint does. Nothing here holds state across calls — the armed timer lives in
|
|
14
|
+
* the returned closure, never at module scope, because Pi loads each extension
|
|
15
|
+
* with its own module cache.
|
|
16
|
+
*
|
|
17
|
+
* The sweep is here because it is the tick's work: a reattached job has no
|
|
18
|
+
* `close` event, so only a periodic pass can notice that its pid is gone.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
|
|
22
|
+
import type { Job, JobRecord, JobStatus } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
export interface RepaintClockDeps {
|
|
25
|
+
/** The repaint interval, read when the clock is armed. */
|
|
26
|
+
repaintMs: () => number;
|
|
27
|
+
/** Whether any record is running, which is the only thing that keeps the clock armed. */
|
|
28
|
+
running: () => boolean;
|
|
29
|
+
/** Whether the runtime was shut down; a disposed runtime arms nothing. */
|
|
30
|
+
disposed: () => boolean;
|
|
31
|
+
/** Refresh this session's liveness marker. */
|
|
32
|
+
touchMarker: () => void;
|
|
33
|
+
/** One tick: the external poll and the chip re-assert. */
|
|
34
|
+
tick: () => void;
|
|
35
|
+
/** One repaint of the surface. */
|
|
36
|
+
repaint: () => void;
|
|
37
|
+
/** The repeating timer, injectable so a test ticks by hand instead of waiting. */
|
|
38
|
+
interval?: RepeatTimer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RepaintClock {
|
|
42
|
+
/** Arm the clock when a job is running and none is armed, and stop it when nothing is. */
|
|
43
|
+
ensure(): void;
|
|
44
|
+
/** Cancel a pending tick. */
|
|
45
|
+
stop(): void;
|
|
46
|
+
/** Ensure the clock, touch the marker, then repaint — unless the runtime is disposed. */
|
|
47
|
+
paint(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createRepaintClock(deps: RepaintClockDeps): RepaintClock {
|
|
51
|
+
const interval = deps.interval ?? defaultInterval;
|
|
52
|
+
let cancel: (() => void) | undefined;
|
|
53
|
+
|
|
54
|
+
const stop = (): void => {
|
|
55
|
+
cancel?.();
|
|
56
|
+
cancel = undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ensure = (): void => {
|
|
60
|
+
if (deps.disposed() || !deps.running()) {
|
|
61
|
+
stop();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (cancel) return;
|
|
65
|
+
cancel = interval(deps.repaintMs(), () => deps.tick());
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const paint = (): void => {
|
|
69
|
+
ensure();
|
|
70
|
+
deps.touchMarker();
|
|
71
|
+
if (deps.disposed()) return;
|
|
72
|
+
deps.repaint();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return { ensure, stop, paint };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the sweep asks of the table; every decision it makes is answered there. */
|
|
79
|
+
export interface ExternalSweepDeps {
|
|
80
|
+
/** Every record in the table. */
|
|
81
|
+
records: () => Iterable<Job>;
|
|
82
|
+
/** This session's id, so a peer's record is told from one's own. */
|
|
83
|
+
sessionId: () => string;
|
|
84
|
+
/**
|
|
85
|
+
* Whether the session that owns a record is still here: `live` and `quiet` both count,
|
|
86
|
+
* because a heartbeat only ticks while a job runs.
|
|
87
|
+
*/
|
|
88
|
+
ownerPresent: (sessionId: string) => boolean;
|
|
89
|
+
/** Whether a pid is still running. */
|
|
90
|
+
alive: (pid: number) => boolean;
|
|
91
|
+
/** Whether a persisted start-time token still matches the pid it names. */
|
|
92
|
+
owns: (job: JobRecord) => boolean;
|
|
93
|
+
/** Settle a record whose process is gone; false when it cannot be judged. */
|
|
94
|
+
settleGone: (job: Job) => boolean;
|
|
95
|
+
/** Move a record to a settled status, resolving its waiters. */
|
|
96
|
+
settle: (job: Job, patch: { status: JobStatus; signal?: string | null }) => void;
|
|
97
|
+
/** SIGTERM a pid. */
|
|
98
|
+
signal: (pid: number) => void;
|
|
99
|
+
/** Refresh this session's liveness marker. */
|
|
100
|
+
touchMarker: () => void;
|
|
101
|
+
/** Prune finished records, persist, and repaint, after something changed. */
|
|
102
|
+
changed: () => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Poll the reattached (unowned) jobs once: settle any whose pid is gone, and reap
|
|
107
|
+
* an abandoned non-detached process whose owner session is gone.
|
|
108
|
+
*/
|
|
109
|
+
export function createExternalSweep(deps: ExternalSweepDeps): () => void {
|
|
110
|
+
return () => {
|
|
111
|
+
deps.touchMarker();
|
|
112
|
+
let changed = false;
|
|
113
|
+
for (const job of deps.records()) {
|
|
114
|
+
if (job.status !== "running" || job.owned) continue;
|
|
115
|
+
const peer = job.sessionId !== deps.sessionId();
|
|
116
|
+
// A peer that is still here owns this job, a quiet one included: leave its state to
|
|
117
|
+
// that owner. The peer's id is asked however often the record says it is not ours.
|
|
118
|
+
if (peer && deps.ownerPresent(job.sessionId)) continue;
|
|
119
|
+
if (job.pid === null || !deps.alive(job.pid)) {
|
|
120
|
+
// A record with no pid cannot be judged gone; leave it alone rather than
|
|
121
|
+
// announcing on every tick.
|
|
122
|
+
if (!deps.settleGone(job)) continue;
|
|
123
|
+
changed = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// The owner is gone and the job is not detached: reap the abandoned
|
|
127
|
+
// process, guarding against pid reuse with the start token. A settle is the
|
|
128
|
+
// whole announcement, so the reap needs no second call of its own.
|
|
129
|
+
if (peer && !job.detached) {
|
|
130
|
+
if (deps.owns(job)) {
|
|
131
|
+
deps.signal(job.pid);
|
|
132
|
+
deps.settle(job, { status: "killed", signal: "SIGTERM" });
|
|
133
|
+
} else {
|
|
134
|
+
deps.settle(job, { status: "unknown" });
|
|
135
|
+
}
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!changed) return;
|
|
140
|
+
deps.changed();
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -6,10 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { withPolledScreen } from "../../lib/screen-session.ts";
|
|
10
9
|
import { formatJobList } from "./format.ts";
|
|
11
10
|
import type { JobsRuntime } from "./runtime.ts";
|
|
12
|
-
import { JobListComponent } from "./tui.ts";
|
|
13
11
|
|
|
14
12
|
/** How often the open log pane re-reads the log file. */
|
|
15
13
|
const LOG_POLL_MS = 500;
|
|
@@ -23,6 +21,10 @@ export function registerCommands(pi: ExtensionAPI, runtime: JobsRuntime): void {
|
|
|
23
21
|
return;
|
|
24
22
|
}
|
|
25
23
|
|
|
24
|
+
// The screen and the dock it mounts are the TUI's, not the listener's: the text
|
|
25
|
+
// listing above and registration load neither.
|
|
26
|
+
const { withPolledScreen } = await import("../../lib/screen-session.ts");
|
|
27
|
+
const { JobListComponent } = await import("./view/jobs-screen.ts");
|
|
26
28
|
await withPolledScreen(
|
|
27
29
|
pi,
|
|
28
30
|
ctx,
|