pi-dag-workflow 0.0.0-stage → 0.7.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/CHANGELOG.md +454 -0
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/README.zh-CN.md +212 -0
- package/docs/COMMUNICATION.md +129 -0
- package/docs/RELEASING.md +66 -0
- package/package.json +70 -4
- package/src/agents/commands/child.ts +148 -0
- package/src/agents/commands/register.ts +404 -0
- package/src/agents/domain/context.ts +183 -0
- package/src/agents/domain/diagnostics.ts +32 -0
- package/src/agents/domain/owner.ts +448 -0
- package/src/agents/domain/profiles.ts +216 -0
- package/src/agents/domain/runtime.ts +603 -0
- package/src/agents/domain/schema.ts +15 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/io/contracts.ts +50 -0
- package/src/agents/io/host.ts +16 -0
- package/src/agents/io/notices.ts +27 -0
- package/src/agents/io/persistence.ts +210 -0
- package/src/agents/io/process.ts +100 -0
- package/src/agents/io/profile-file.ts +16 -0
- package/src/agents/io/skills.ts +46 -0
- package/src/goal/commands/register.ts +235 -0
- package/src/goal/domain/failure.ts +14 -0
- package/src/goal/domain/owner.ts +575 -0
- package/src/goal/domain/policy.ts +8 -0
- package/src/goal/domain/prompt.ts +57 -0
- package/src/goal/domain/state.ts +176 -0
- package/src/goal/index.ts +5 -0
- package/src/goal/io/host.ts +8 -0
- package/src/plan/commands/register.ts +101 -0
- package/src/plan/domain/owner.ts +51 -0
- package/src/plan/domain/policy.ts +27 -0
- package/src/plan/domain/state.ts +15 -0
- package/src/plan/index.ts +13 -0
- package/src/plan/io/records.ts +3 -0
- package/src/retry/commands/menu.ts +158 -0
- package/src/retry/commands/register.ts +26 -0
- package/src/retry/domain/owner.ts +418 -0
- package/src/retry/domain/state.ts +71 -0
- package/src/retry/index.ts +5 -0
- package/src/retry/io/host.ts +15 -0
- package/src/shared/agent-view.ts +18 -0
- package/src/shared/completion.ts +111 -0
- package/src/shared/config-file.ts +106 -0
- package/src/shared/config.ts +112 -0
- package/src/shared/dag/cache.ts +26 -0
- package/src/shared/dag/graph.ts +170 -0
- package/src/shared/dag/layout.ts +130 -0
- package/src/shared/goal-view.ts +2 -0
- package/src/shared/i18n.ts +62 -0
- package/src/shared/message-types.ts +12 -0
- package/src/shared/messages.en.ts +1290 -0
- package/src/shared/passive-message.ts +50 -0
- package/src/shared/pi-cli.ts +29 -0
- package/src/shared/profile-launch.ts +110 -0
- package/src/shared/state-types.ts +5 -0
- package/src/shared/task-schema.ts +39 -0
- package/src/shared/task-types.ts +25 -0
- package/src/shared/terminal.ts +19 -0
- package/src/shared/tool-info.ts +8 -0
- package/src/shared/ui/filter.ts +144 -0
- package/src/shared/ui/menu.ts +447 -0
- package/src/shared/ui/panel.ts +768 -0
- package/src/shared/ui/presentation.ts +624 -0
- package/src/shared/ui/render-cache.ts +63 -0
- package/src/shared/ui/render.ts +641 -0
- package/src/shared/ui/symbols.ts +136 -0
- package/src/team/commands/bootstrap.ts +52 -0
- package/src/team/commands/controls.ts +91 -0
- package/src/team/commands/history.ts +100 -0
- package/src/team/commands/menu.ts +455 -0
- package/src/team/commands/register.ts +1321 -0
- package/src/team/domain/inbox.ts +178 -0
- package/src/team/domain/launcher.ts +355 -0
- package/src/team/domain/lifecycle.ts +34 -0
- package/src/team/domain/teams.ts +161 -0
- package/src/team/index.ts +4 -0
- package/src/team/io/inbox.ts +255 -0
- package/src/team/io/launcher.ts +270 -0
- package/src/team/io/lock.ts +19 -0
- package/src/team/io/registry.ts +477 -0
- package/src/team/io/runtime.ts +353 -0
- package/src/team/io/session-activity.ts +185 -0
- package/src/team/io/session-delete.ts +98 -0
- package/src/team/io/session-history.ts +153 -0
- package/src/team/io/session-tail.ts +132 -0
- package/src/team/io/sid-lock.ts +115 -0
- package/src/team/io/sid-session.ts +98 -0
- package/src/todos/commands/checkpoints.ts +31 -0
- package/src/todos/commands/register.ts +395 -0
- package/src/todos/domain/checkpoint-state.ts +32 -0
- package/src/todos/domain/checkpoint-summary.ts +146 -0
- package/src/todos/domain/cleanup.ts +25 -0
- package/src/todos/domain/owner.ts +109 -0
- package/src/todos/domain/presets.ts +192 -0
- package/src/todos/domain/state.ts +287 -0
- package/src/todos/index.ts +15 -0
- package/src/todos/io/records.ts +8 -0
- package/src/ui/index.ts +4 -0
- package/src/workflow/commands/connect.ts +81 -0
- package/src/workflow/commands/view.ts +121 -0
- package/src/workflow/domain/features.ts +5 -0
- package/src/workflow/domain/view-owner.ts +18 -0
- package/src/workflow/domain/view-state.ts +12 -0
- package/src/workflow/index.ts +225 -0
- package/src/workflow/io/session.ts +48 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { spawn as nodeSpawn, execFileSync, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { access, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { BACKGROUND_ENV, detectAdapters, detectTerminal, memberArgs, planLaunch, type LaunchAdapters, type LaunchMode, type LaunchPlan, type LaunchRequest, type LaunchRuntime, type TerminalApp } from '../domain/launcher.ts';
|
|
7
|
+
import { writeRuntimePid } from './runtime.ts';
|
|
8
|
+
import { readLaunchProfile, type LaunchProfile } from '../../shared/profile-launch.ts';
|
|
9
|
+
import { resolveChildExtensionPaths } from '../../shared/config.ts';
|
|
10
|
+
|
|
11
|
+
export interface LaunchDeps {
|
|
12
|
+
spawn?: typeof nodeSpawn;
|
|
13
|
+
adapters?: LaunchAdapters;
|
|
14
|
+
/** Tests replace the FIFO maker and opener so nothing touches the filesystem. */
|
|
15
|
+
mkfifo?: (path: string) => void;
|
|
16
|
+
openFifo?: (path: string) => number | 'ignore';
|
|
17
|
+
/** How long a background member gets to prove it is alive; tests skip the wait. */
|
|
18
|
+
settleMs?: number;
|
|
19
|
+
wait?: (ms: number) => Promise<void>;
|
|
20
|
+
alive?: (pid: number) => boolean;
|
|
21
|
+
/** Provenance check for the files a failed launch removes: only a path it approves is deleted. */
|
|
22
|
+
ownedRuntimeFile?: (path: string) => Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LaunchResult {
|
|
26
|
+
plan: LaunchPlan;
|
|
27
|
+
child?: ChildProcess;
|
|
28
|
+
profile?: LaunchProfile;
|
|
29
|
+
/** True only after the process is confirmed alive; `spawn()` returning means nothing. */
|
|
30
|
+
spawned: boolean;
|
|
31
|
+
/** Why a `background` launch is not running; carries the log's first line verbatim. */
|
|
32
|
+
reason?: string;
|
|
33
|
+
/** What a failed background launch did with its runtime files: kept files were refused, not cleaned. */
|
|
34
|
+
cleanup?: { removed: number; kept: number };
|
|
35
|
+
/** True only where this launch verified the member itself (background binding); a window is a request. */
|
|
36
|
+
confirmed?: boolean;
|
|
37
|
+
/** The PID the child process really reported, and the three files this launch owns. */
|
|
38
|
+
pid?: number;
|
|
39
|
+
runtime?: LaunchRuntime;
|
|
40
|
+
/** Set when the child this launch started never reported an exit: `spawned` is false and these files stayed. */
|
|
41
|
+
stopped?: false;
|
|
42
|
+
keptFiles?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const makeFifo = (path: string) => {
|
|
46
|
+
// The runtime directory is ours: created 0700, and the host umask is never changed for it. The mode is
|
|
47
|
+
// set on the FIFO explicitly, so a runtime file is owner-only like the log and the PID sidecar.
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
49
|
+
execFileSync('mkfifo', ['-m', '600', path]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* tmux is an environment fact; a desktop terminal has to exist on PATH. The configuration decides
|
|
54
|
+
* first: an explicit terminal is used as-is, `none` disables the row, `auto` walks the platform table.
|
|
55
|
+
*/
|
|
56
|
+
export function detectLaunchAdapters(config: { terminal?: TerminalApp | 'auto' | 'none'; template?: string } = {}, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): LaunchAdapters {
|
|
57
|
+
const available = (name: string) => { try { execFileSync('sh', ['-c', `command -v ${name}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
58
|
+
const wanted = config.terminal ?? 'auto';
|
|
59
|
+
const terminal = wanted === 'none' ? undefined : wanted === 'auto' ? detectTerminal(available, platform) : wanted;
|
|
60
|
+
return {
|
|
61
|
+
...detectAdapters(env, platform),
|
|
62
|
+
...(terminal ? { terminal } : {}),
|
|
63
|
+
...(config.terminal ? { desktopTerminal: config.terminal } : {}),
|
|
64
|
+
...(config.template ? { desktopTemplate: config.template } : {}),
|
|
65
|
+
available, platform,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));const firstLine = (path: string): string | undefined => {
|
|
69
|
+
try { return readFileSync(path, 'utf8').split('\n').map(line => line.trim()).find(Boolean); } catch { return undefined; }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Terminate only the child this launch started, through its own handle, and report whether it really exited.
|
|
74
|
+
* Two bounded signals (SIGTERM, then SIGKILL) and never a PID: a handle whose `kill()` fails, or that never
|
|
75
|
+
* reports an exit, has not been stopped — so the caller must keep the runtime files instead of deleting them.
|
|
76
|
+
*/
|
|
77
|
+
export async function stopChild(child: ChildProcess | undefined, deps: { wait?: (ms: number) => Promise<void> } = {}): Promise<boolean> {
|
|
78
|
+
if (!child) return false;
|
|
79
|
+
// Only the child's own exit code or signal counts. `child.killed` records that a signal was sent, not that
|
|
80
|
+
// the process is gone, so it is never read here.
|
|
81
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
82
|
+
// An exit that is already visible needs no signal; a handle that cannot be signalled is not an exit either.
|
|
83
|
+
if (exited()) return true;
|
|
84
|
+
if (typeof child.kill !== 'function') return false;
|
|
85
|
+
const wait = deps.wait ?? sleep;
|
|
86
|
+
// A kill that throws is not an exit either: it is reported as an unconfirmed stop, never as a crash.
|
|
87
|
+
const signal = (name: NodeJS.Signals) => { try { child.kill(name); } catch { /* unconfirmed below */ } };
|
|
88
|
+
signal('SIGTERM');
|
|
89
|
+
for (let attempt = 0; attempt < 40 && !exited(); attempt += 1) await wait(50);
|
|
90
|
+
if (!exited()) signal('SIGKILL');
|
|
91
|
+
for (let attempt = 0; attempt < 20 && !exited(); attempt += 1) await wait(50);
|
|
92
|
+
return exited();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Production launcher; tests inject `spawn` so no real Pi process is started.
|
|
97
|
+
* `background` needs `--mode rpc` plus a FIFO on stdin: a detached `pi` without a TTY exits at once.
|
|
98
|
+
* A background launch only counts as spawned while the process is still running; a failure removes
|
|
99
|
+
* the FIFO and log it just created and reports the log's first line instead of a bare "failed".
|
|
100
|
+
*/
|
|
101
|
+
/** The Team extension entry this package ships: an isolated child has to load it to own `--team`. */
|
|
102
|
+
export const teamExtensionPath = (): string => fileURLToPath(new URL('../index.ts', import.meta.url));
|
|
103
|
+
|
|
104
|
+
export async function launchTeam(mode: LaunchMode, baseRequest: LaunchRequest, deps: LaunchDeps = {}, profile: LaunchProfile | undefined = undefined): Promise<LaunchResult> {
|
|
105
|
+
const adapters = deps.adapters ?? detectAdapters(process.env), spawn = deps.spawn ?? nodeSpawn;
|
|
106
|
+
const selected = profile ?? readLaunchProfile(baseRequest.profile);
|
|
107
|
+
// An isolated launch resolves its extension entries against the configuration directory once, before the
|
|
108
|
+
// plan and before the spawn: a relative entry must never be satisfied by a namesake file in the working
|
|
109
|
+
// directory. Entries a caller already resolved come back as the same real path.
|
|
110
|
+
const resolved = selected?.isolate
|
|
111
|
+
? { ...selected, childExtensions: await resolveChildExtensionPaths([...(selected.childExtensions ?? [])]) }
|
|
112
|
+
: selected;
|
|
113
|
+
// The plan and the spawn must name the same extension entries, so an isolated launch gets its own Team
|
|
114
|
+
// entry here, once, before anything is planned or started.
|
|
115
|
+
const request = baseRequest.teamExtension ? baseRequest : { ...baseRequest, teamExtension: teamExtensionPath() };
|
|
116
|
+
const plan = planLaunch(mode, request, adapters, resolved);
|
|
117
|
+
const withProfile = resolved ? { profile: resolved } : {};
|
|
118
|
+
if (mode === 'copy' || plan.mode === 'copy') return { plan, spawned: false, ...withProfile };
|
|
119
|
+
// Interpreter, then CLI entry, then the same member args the paste-able command uses.
|
|
120
|
+
const args = [...(request.cli ? [request.cli] : []), ...memberArgs(request, resolved)];
|
|
121
|
+
if (mode === 'background') {
|
|
122
|
+
// The caller generated this pair once for this launch: the plan, the spawned process and the member
|
|
123
|
+
// binding that follows all name the same three files inside the team runtime root. There is no
|
|
124
|
+
// working-directory fallback — a fixed file per display name is exactly what collided before.
|
|
125
|
+
const runtime = request.runtime;
|
|
126
|
+
if (!runtime) throw new Error('后台启动缺少团队运行目录里的运行文件:拒绝启动,不在工作目录里留固定文件');
|
|
127
|
+
(deps.mkfifo ?? makeFifo)(runtime.fifo);
|
|
128
|
+
mkdirSync(dirname(runtime.log), { recursive: true, mode: 0o700 });
|
|
129
|
+
// `r+` keeps a write end open inside the child, so RPC never sees EOF on stdin.
|
|
130
|
+
const stdin = (deps.openFifo ?? ((path: string) => openSync(path, 'r+')))(runtime.fifo);
|
|
131
|
+
// The log is a runtime file too: created 0600, not world readable through the host umask.
|
|
132
|
+
const out = openSync(runtime.log, 'a', 0o600);
|
|
133
|
+
let child: ChildProcess | undefined;
|
|
134
|
+
let spawnError: string | undefined;
|
|
135
|
+
try {
|
|
136
|
+
child = spawn(request.execPath, [...args, '--mode', 'rpc'], { cwd: request.cwd, detached: true, stdio: [stdin, out, out], env: { ...process.env, [BACKGROUND_ENV]: '1' } });
|
|
137
|
+
// A detached member must not keep us alive, and an ENOENT on execPath must not crash the host.
|
|
138
|
+
child.on('error', (error: Error) => { spawnError = error.message; });
|
|
139
|
+
child.unref?.();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// spawn() itself can throw; that is still a failed launch, not a reason to leave its files behind.
|
|
142
|
+
spawnError = error instanceof Error ? error.message : String(error);
|
|
143
|
+
} finally { if (typeof stdin === 'number') closeSync(stdin); if (typeof out === 'number') closeSync(out); }
|
|
144
|
+
if (!child) {
|
|
145
|
+
// No process exists at all, so nothing can still hold these files: the provenance guard decides.
|
|
146
|
+
const cleaned = await cleanupRuntime([runtime.fifo, runtime.log, runtime.pidFile], deps.ownedRuntimeFile);
|
|
147
|
+
return { plan, spawned: false, ...withProfile, cleanup: cleaned, ...(spawnError ? { reason: spawnError } : {}) };
|
|
148
|
+
}
|
|
149
|
+
const alive = deps.alive ?? ((target: number) => { try { process.kill(target, 0); return true; } catch { return false; } });
|
|
150
|
+
await (deps.wait ?? sleep)(deps.settleMs ?? 800);
|
|
151
|
+
const pid = typeof child.pid === 'number' ? child.pid : undefined;
|
|
152
|
+
const running = child.exitCode === null && child.signalCode === null && pid !== undefined && alive(pid);
|
|
153
|
+
if (running) {
|
|
154
|
+
try {
|
|
155
|
+
// Only a process that really runs gets a PID written: it is the evidence a stop and a cleanup read
|
|
156
|
+
// later, and a pre-made or guessed PID would prove nothing about this pair.
|
|
157
|
+
await writeRuntimePid(runtime.pidFile, pid);
|
|
158
|
+
return { plan, child, spawned: true, pid, runtime, ...withProfile };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Without its sidecar this launch has no PID evidence at all, so the child this call started has to
|
|
161
|
+
// go. If it does not actually report an exit, the files stay: deleting them would remove the channel
|
|
162
|
+
// of a process nobody can vouch for, and "stopped" would be a claim this call cannot make.
|
|
163
|
+
const exited = await stopChild(child, { ...(deps.wait ? { wait: deps.wait } : {}) });
|
|
164
|
+
if (!exited) {
|
|
165
|
+
const kept = await cleanupRuntime([runtime.fifo, runtime.log, runtime.pidFile], undefined);
|
|
166
|
+
return {
|
|
167
|
+
plan, spawned: false, ...withProfile, stopped: false, keptFiles: kept.kept,
|
|
168
|
+
reason: `${error instanceof Error ? error.message : String(error)};未能确认退出,运行文件保留 ${kept.kept} 个,需人工检查`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const cleaned = await cleanupRuntime([runtime.fifo, runtime.log, runtime.pidFile], deps.ownedRuntimeFile);
|
|
172
|
+
return { plan, spawned: false, ...withProfile, cleanup: cleaned, reason: error instanceof Error ? error.message : String(error) };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const line = firstLine(runtime.log) ?? spawnError;
|
|
176
|
+
// A failed liveness probe is not an exit: the child's own handle has to report one before its files may
|
|
177
|
+
// go. This is the same rule and the same narrow stop helper as the sidecar failure above — a dead probe,
|
|
178
|
+
// a missing sidecar or a short settle never stands in for a confirmed exit.
|
|
179
|
+
const exited = await stopChild(child, { ...(deps.wait ? { wait: deps.wait } : {}) });
|
|
180
|
+
if (!exited) {
|
|
181
|
+
const kept = await cleanupRuntime([runtime.fifo, runtime.log, runtime.pidFile], undefined);
|
|
182
|
+
const keptNote = kept.kept ? `未能确认退出,运行文件保留 ${kept.kept} 个,需人工检查` : '未能确认退出,需人工检查';
|
|
183
|
+
return { plan, spawned: false, ...withProfile, stopped: false, keptFiles: kept.kept, reason: line ? `${line};${keptNote}` : keptNote };
|
|
184
|
+
}
|
|
185
|
+
const cleaned = await cleanupRuntime([runtime.fifo, runtime.log, runtime.pidFile], deps.ownedRuntimeFile);
|
|
186
|
+
// Files the provenance check refused are reported: "kept" is not "cleaned up".
|
|
187
|
+
return { plan, spawned: false, ...withProfile, cleanup: cleaned, ...(line ? { reason: line } : {}) };
|
|
188
|
+
}
|
|
189
|
+
// The plan already decided how this window starts: a user template goes to the platform shell,
|
|
190
|
+
// a known emulator gets its argv directly, and a degraded plan never reaches a spawn at all.
|
|
191
|
+
if (plan.exec) {
|
|
192
|
+
const desktop = mode === 'desktop';
|
|
193
|
+
const wait = deps.wait ?? sleep, settleMs = deps.settleMs ?? 500;
|
|
194
|
+
let spawnError: string | undefined, diagnostics = '';
|
|
195
|
+
let child: ChildProcess;
|
|
196
|
+
try {
|
|
197
|
+
child = spawn(plan.exec.file, plan.exec.args, { cwd: request.cwd, ...(desktop ? { detached: true } : {}), stdio: ['ignore', 'ignore', 'pipe'] });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// A synchronous spawn failure is a failed launch, not an exception for the host to take.
|
|
200
|
+
return { plan, spawned: false, ...withProfile, reason: spawnFailure(error) };
|
|
201
|
+
}
|
|
202
|
+
// The listener stays for the process lifetime: a spawn that fails later must never be an uncaught 'error'.
|
|
203
|
+
// A handle supplied through `deps.spawn` may carry no streams; a real ChildProcess always has them.
|
|
204
|
+
child.on?.('error', (error: Error) => { spawnError = error.message; });
|
|
205
|
+
// The cap is enforced on every append, so one huge chunk cannot grow the buffer past it. `capturing`
|
|
206
|
+
// only stops collecting; the pipe keeps being drained, so the child never meets a closed stderr.
|
|
207
|
+
let capturing = true;
|
|
208
|
+
child.stderr?.on?.('data', (chunk: Buffer | string) => {
|
|
209
|
+
if (!capturing) return;
|
|
210
|
+
diagnostics = (diagnostics + String(chunk)).slice(0, STDERR_LIMIT);
|
|
211
|
+
});
|
|
212
|
+
child.unref?.();
|
|
213
|
+
await wait(settleMs);
|
|
214
|
+
// Diagnostics are the child's own text: reading them as a file path would both lose the message and let
|
|
215
|
+
// a same-named file in the working directory stand in for it.
|
|
216
|
+
const captured = bounded(diagnostics);
|
|
217
|
+
capturing = false;
|
|
218
|
+
// Stop collecting without touching the pipe: `destroy()` here used to hand a still-running child EPIPE.
|
|
219
|
+
// A real pipe is a socket and can be unref'd; a handle that cannot be is simply left draining.
|
|
220
|
+
(child.stderr as unknown as { unref?: () => void } | undefined)?.unref?.();
|
|
221
|
+
// A failure that is already visible is reported with its own words instead of a claim of success.
|
|
222
|
+
if (spawnError !== undefined) return { plan, spawned: false, ...withProfile, reason: bounded(spawnError) };
|
|
223
|
+
// Only an exit that was actually observed counts: a handle without exit state has reported nothing.
|
|
224
|
+
if ('exitCode' in child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
225
|
+
const ended = child.exitCode !== null ? `退出码 ${child.exitCode}` : `信号 ${child.signalCode}`;
|
|
226
|
+
// A terminal emulator is a service client: it may hand the window over and exit 0 immediately, so a
|
|
227
|
+
// clean fast exit proves nothing either way. Any non-zero exit is a launch that did not start.
|
|
228
|
+
if (child.exitCode !== 0) return { plan, spawned: false, ...withProfile, reason: captured ? `${ended}:${captured}` : ended };
|
|
229
|
+
return { plan, spawned: true, confirmed: false, ...withProfile };
|
|
230
|
+
}
|
|
231
|
+
// Still running after the bounded window: the request was accepted, the member is not verified here.
|
|
232
|
+
return { plan, child, spawned: true, confirmed: false, ...withProfile };
|
|
233
|
+
}
|
|
234
|
+
return { plan, spawned: false, ...withProfile };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A spawn error is data, never an exception the host has to survive. */
|
|
238
|
+
const spawnFailure = (error: unknown): string => bounded(error instanceof Error ? error.message : String(error));
|
|
239
|
+
/** How much of a child's stderr a failed launch may quote back. */
|
|
240
|
+
const STDERR_LIMIT = 400;
|
|
241
|
+
/** The first non-blank line of *text*; never a filesystem read. */
|
|
242
|
+
const firstLineOf = (text: string): string | undefined => text.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
243
|
+
/** Diagnostics are bounded: the first line, at most 300 characters, never a whole log. */
|
|
244
|
+
const bounded = (text: string): string => { const line = firstLineOf(text) ?? ''; return line.slice(0, 300); };
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A failed launch leaves nothing behind — but only files that are provably inside this team's runtime
|
|
248
|
+
* root. There is no "no guard" mode: without a provenance check nothing is deleted, and the caller is
|
|
249
|
+
* told the files that were kept instead of being told the launch cleaned up after itself. A file this
|
|
250
|
+
* launch never created (the sidecar of a child that died before it ran) is not a kept file.
|
|
251
|
+
*/
|
|
252
|
+
export async function cleanupRuntime(
|
|
253
|
+
paths: readonly (string | undefined)[],
|
|
254
|
+
guard: ((path: string) => Promise<boolean>) | undefined,
|
|
255
|
+
): Promise<{ removed: number; kept: number }> {
|
|
256
|
+
let removed = 0, kept = 0;
|
|
257
|
+
for (const path of paths) {
|
|
258
|
+
if (!path) continue;
|
|
259
|
+
// Only an absent file is nothing to clean. Any other access failure (permission, IO) means this call
|
|
260
|
+
// cannot prove the file is gone, so it stays and is reported as kept instead of vanishing silently.
|
|
261
|
+
try { await access(path); }
|
|
262
|
+
catch (error) {
|
|
263
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') kept += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!guard || !await guard(path)) { kept += 1; continue; }
|
|
267
|
+
try { await rm(path); removed += 1; } catch { kept += 1; }
|
|
268
|
+
}
|
|
269
|
+
return { removed, kept };
|
|
270
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { open, rm } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Exclusive Team IO lock. One writer per path: the whole read→modify→write runs inside `run`, so a
|
|
7
|
+
* second writer waits instead of overwriting the first one's snapshot. A rename alone only makes the
|
|
8
|
+
* replacement complete, never the transaction.
|
|
9
|
+
*/
|
|
10
|
+
export async function withLock<T>(path: string, run: () => Promise<T>, busy = '任务目录正被其它进程修改;稍后重试'): Promise<T> {
|
|
11
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
12
|
+
let handle;
|
|
13
|
+
try { handle = await open(path, 'wx', 0o600); }
|
|
14
|
+
catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'EEXIST') throw cause; await sleep(20); continue; }
|
|
15
|
+
try { return await run(); }
|
|
16
|
+
finally { await handle.close().catch(() => {}); await rm(path, { force: true }).catch(() => {}); }
|
|
17
|
+
}
|
|
18
|
+
throw new Error(busy);
|
|
19
|
+
}
|