tinker-agent 1.0.65
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 +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
RuntimeSessionContext,
|
|
6
|
+
SessionDisposeReason,
|
|
7
|
+
} from "../agent/runtime-session";
|
|
8
|
+
import type { ToolCallIdentity } from "../agent/types";
|
|
9
|
+
import { createUuidV7 } from "../ids/uuid-v7";
|
|
10
|
+
import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
|
|
11
|
+
import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
|
|
12
|
+
|
|
13
|
+
export type ShellTaskStatus =
|
|
14
|
+
| "running"
|
|
15
|
+
| "stopping"
|
|
16
|
+
| "completed"
|
|
17
|
+
| "failed"
|
|
18
|
+
| "killed";
|
|
19
|
+
|
|
20
|
+
export type BackgroundReason = "requested" | "foreground_timeout";
|
|
21
|
+
|
|
22
|
+
export type ShellTaskOrigin = ToolCallIdentity;
|
|
23
|
+
|
|
24
|
+
export type ShellTaskSnapshot = {
|
|
25
|
+
taskId: string;
|
|
26
|
+
origin: ShellTaskOrigin;
|
|
27
|
+
command: string;
|
|
28
|
+
description: string;
|
|
29
|
+
status: ShellTaskStatus;
|
|
30
|
+
exitCode?: number;
|
|
31
|
+
signal?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
startedAt: string;
|
|
34
|
+
endedAt?: string;
|
|
35
|
+
backgroundedAt?: string;
|
|
36
|
+
backgroundReason?: BackgroundReason;
|
|
37
|
+
outputFilePath: string;
|
|
38
|
+
outputBytes: number;
|
|
39
|
+
outputLines: number;
|
|
40
|
+
cwd: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ShellTaskInspection = {
|
|
44
|
+
task: ShellTaskSnapshot;
|
|
45
|
+
output: TaskOutputSnapshot;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ShellTaskHandle = {
|
|
49
|
+
taskId: string;
|
|
50
|
+
completion: Promise<ShellTaskSnapshot>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type StopTaskReason = "tool" | "shutdown" | "turn_cancelled";
|
|
54
|
+
|
|
55
|
+
export type StopTaskResult = {
|
|
56
|
+
task: ShellTaskSnapshot;
|
|
57
|
+
requestedSignal: "SIGTERM";
|
|
58
|
+
escalated: boolean;
|
|
59
|
+
reason: StopTaskReason;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ShutdownResult = {
|
|
63
|
+
reason: SessionDisposeReason["type"];
|
|
64
|
+
stoppedTaskIds: string[];
|
|
65
|
+
escalatedTaskIds: string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ManagedShellTask = {
|
|
69
|
+
id: string;
|
|
70
|
+
origin: ShellTaskOrigin;
|
|
71
|
+
command: string;
|
|
72
|
+
description: string;
|
|
73
|
+
status: ShellTaskStatus;
|
|
74
|
+
exitCode?: number;
|
|
75
|
+
signal?: string;
|
|
76
|
+
error?: string;
|
|
77
|
+
startedAt: string;
|
|
78
|
+
endedAt?: string;
|
|
79
|
+
backgroundedAt?: string;
|
|
80
|
+
backgroundReason?: BackgroundReason;
|
|
81
|
+
outputFilePath: string;
|
|
82
|
+
cwdFilePath: string;
|
|
83
|
+
cwd: string;
|
|
84
|
+
process: ChildProcessWithoutNullStreams;
|
|
85
|
+
processGroupId: number;
|
|
86
|
+
output: TaskOutput;
|
|
87
|
+
completion: Promise<ShellTaskSnapshot>;
|
|
88
|
+
stopPromise?: Promise<StopTaskResult>;
|
|
89
|
+
terminalEventEmitted: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type ShellTaskManagerOptions = {
|
|
93
|
+
workspaceRoot: string;
|
|
94
|
+
cwdState: CwdState;
|
|
95
|
+
runtimeSession: RuntimeSessionContext;
|
|
96
|
+
stopGraceMs?: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const defaultStopGraceMs = 2_000;
|
|
100
|
+
|
|
101
|
+
export class ShellTaskManager {
|
|
102
|
+
private readonly tasks = new Map<string, ManagedShellTask>();
|
|
103
|
+
private readonly stopGraceMs: number;
|
|
104
|
+
private acceptingTasks = true;
|
|
105
|
+
private shutdownPromise?: Promise<ShutdownResult>;
|
|
106
|
+
|
|
107
|
+
constructor(private readonly options: ShellTaskManagerOptions) {
|
|
108
|
+
this.stopGraceMs = options.stopGraceMs ?? defaultStopGraceMs;
|
|
109
|
+
if (!Number.isInteger(this.stopGraceMs) || this.stopGraceMs < 1) {
|
|
110
|
+
throw new Error("ShellTaskManager.stopGraceMs must be a positive integer.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async start(input: {
|
|
115
|
+
command: string;
|
|
116
|
+
description: string;
|
|
117
|
+
origin: ShellTaskOrigin;
|
|
118
|
+
}): Promise<ShellTaskHandle> {
|
|
119
|
+
if (!this.acceptingTasks) {
|
|
120
|
+
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = createUuidV7();
|
|
124
|
+
const outputFilePath = path.join(
|
|
125
|
+
this.options.workspaceRoot,
|
|
126
|
+
".tinker",
|
|
127
|
+
"bash",
|
|
128
|
+
`${id}.log`,
|
|
129
|
+
);
|
|
130
|
+
const cwdFilePath = path.join(
|
|
131
|
+
this.options.workspaceRoot,
|
|
132
|
+
".tinker",
|
|
133
|
+
"bash",
|
|
134
|
+
`${id}.cwd`,
|
|
135
|
+
);
|
|
136
|
+
await ensureEmptyFile(cwdFilePath);
|
|
137
|
+
|
|
138
|
+
const output = await TaskOutput.create(outputFilePath);
|
|
139
|
+
if (!this.acceptingTasks) {
|
|
140
|
+
await output.end();
|
|
141
|
+
await unlinkIfExists(cwdFilePath);
|
|
142
|
+
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const child = spawn("bash", ["-lc", bashWrapperScript], {
|
|
146
|
+
cwd: this.options.cwdState.cwd,
|
|
147
|
+
detached: true,
|
|
148
|
+
env: {
|
|
149
|
+
...process.env,
|
|
150
|
+
NO_COLOR: "1",
|
|
151
|
+
TINKER_BASH_COMMAND: input.command,
|
|
152
|
+
TINKER_BASH_CWD_FILE: cwdFilePath,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (child.pid === undefined) {
|
|
157
|
+
const spawnError = await waitForProcessError(child);
|
|
158
|
+
await output.end();
|
|
159
|
+
await unlinkIfExists(cwdFilePath);
|
|
160
|
+
throw spawnError;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const task: ManagedShellTask = {
|
|
164
|
+
id,
|
|
165
|
+
origin: input.origin,
|
|
166
|
+
command: input.command,
|
|
167
|
+
description: input.description,
|
|
168
|
+
status: "running",
|
|
169
|
+
startedAt: new Date().toISOString(),
|
|
170
|
+
outputFilePath,
|
|
171
|
+
cwdFilePath,
|
|
172
|
+
cwd: this.options.cwdState.cwd,
|
|
173
|
+
process: child,
|
|
174
|
+
processGroupId: child.pid,
|
|
175
|
+
output,
|
|
176
|
+
completion: Promise.resolve(undefined as never),
|
|
177
|
+
terminalEventEmitted: false,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
pipeTaskOutput(task.process.stdout, task.output);
|
|
181
|
+
pipeTaskOutput(task.process.stderr, task.output);
|
|
182
|
+
task.completion = this.monitorTaskSafely(task);
|
|
183
|
+
this.tasks.set(id, task);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
taskId: id,
|
|
187
|
+
completion: task.completion,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async markBackgrounded(
|
|
192
|
+
taskId: string,
|
|
193
|
+
reason: BackgroundReason,
|
|
194
|
+
): Promise<ShellTaskSnapshot> {
|
|
195
|
+
const task = this.requireTask(taskId);
|
|
196
|
+
this.synchronizeTerminalState(task);
|
|
197
|
+
|
|
198
|
+
if (task.backgroundedAt !== undefined) {
|
|
199
|
+
return this.snapshot(task);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
task.backgroundedAt = new Date().toISOString();
|
|
203
|
+
task.backgroundReason = reason;
|
|
204
|
+
if (isTerminalStatus(task.status)) {
|
|
205
|
+
task.terminalEventEmitted = true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const snapshot = this.snapshot(task);
|
|
209
|
+
await this.options.runtimeSession.append({
|
|
210
|
+
type: "bash.task.backgrounded",
|
|
211
|
+
...task.origin,
|
|
212
|
+
data: { task: snapshot },
|
|
213
|
+
});
|
|
214
|
+
return snapshot;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
listBackgroundTasks(): ShellTaskSnapshot[] {
|
|
218
|
+
return [...this.tasks.values()]
|
|
219
|
+
.filter((task) => task.backgroundedAt !== undefined)
|
|
220
|
+
.map((task) => {
|
|
221
|
+
this.synchronizeTerminalState(task);
|
|
222
|
+
return this.snapshot(task);
|
|
223
|
+
})
|
|
224
|
+
.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
inspectTask(taskId: string): ShellTaskInspection | undefined {
|
|
228
|
+
const task = this.tasks.get(taskId);
|
|
229
|
+
if (task === undefined) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.synchronizeTerminalState(task);
|
|
234
|
+
return {
|
|
235
|
+
task: this.snapshot(task),
|
|
236
|
+
output: task.output.snapshot(),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async stopTask(taskId: string, reason: StopTaskReason): Promise<StopTaskResult> {
|
|
241
|
+
const task = this.requireTask(taskId);
|
|
242
|
+
this.synchronizeTerminalState(task);
|
|
243
|
+
|
|
244
|
+
if (task.status === "stopping") {
|
|
245
|
+
throw new Error(`Task ${taskId} is already stopping.`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isTerminalStatus(task.status)) {
|
|
249
|
+
throw new Error(`Task ${taskId} is not running (status=${task.status}).`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return this.beginStop(task, reason);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async cancelForegroundTask(taskId: string): Promise<ShellTaskSnapshot> {
|
|
256
|
+
const task = this.requireTask(taskId);
|
|
257
|
+
this.synchronizeTerminalState(task);
|
|
258
|
+
|
|
259
|
+
if (task.backgroundedAt !== undefined) {
|
|
260
|
+
throw new Error(`Cannot cancel background task as foreground: ${taskId}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (isTerminalStatus(task.status) || task.status === "stopping") {
|
|
264
|
+
return task.completion;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (await this.beginStop(task, "turn_cancelled")).task;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
shutdown(reason: SessionDisposeReason["type"]): Promise<ShutdownResult> {
|
|
271
|
+
if (this.shutdownPromise !== undefined) {
|
|
272
|
+
return this.shutdownPromise;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.acceptingTasks = false;
|
|
276
|
+
this.shutdownPromise = this.performShutdown(reason);
|
|
277
|
+
return this.shutdownPromise;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async performShutdown(
|
|
281
|
+
reason: SessionDisposeReason["type"],
|
|
282
|
+
): Promise<ShutdownResult> {
|
|
283
|
+
const results = await Promise.all(
|
|
284
|
+
[...this.tasks.values()].map(async (task) => {
|
|
285
|
+
this.synchronizeTerminalState(task);
|
|
286
|
+
if (isTerminalStatus(task.status)) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (task.status === "stopping") {
|
|
291
|
+
return (
|
|
292
|
+
task.stopPromise ??
|
|
293
|
+
task.completion.then((snapshot) => ({
|
|
294
|
+
task: snapshot,
|
|
295
|
+
requestedSignal: "SIGTERM" as const,
|
|
296
|
+
escalated: false,
|
|
297
|
+
reason: "shutdown" as const,
|
|
298
|
+
}))
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return this.beginStop(task, "shutdown");
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const stopped = results.filter(
|
|
307
|
+
(result): result is StopTaskResult => result !== undefined,
|
|
308
|
+
);
|
|
309
|
+
return {
|
|
310
|
+
reason,
|
|
311
|
+
stoppedTaskIds: stopped.map((result) => result.task.taskId),
|
|
312
|
+
escalatedTaskIds: stopped
|
|
313
|
+
.filter((result) => result.escalated)
|
|
314
|
+
.map((result) => result.task.taskId),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private beginStop(
|
|
319
|
+
task: ManagedShellTask,
|
|
320
|
+
reason: StopTaskReason,
|
|
321
|
+
): Promise<StopTaskResult> {
|
|
322
|
+
const stopPromise = this.performStop(task, reason);
|
|
323
|
+
task.stopPromise = stopPromise;
|
|
324
|
+
return stopPromise;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async performStop(
|
|
328
|
+
task: ManagedShellTask,
|
|
329
|
+
reason: StopTaskReason,
|
|
330
|
+
): Promise<StopTaskResult> {
|
|
331
|
+
task.status = "stopping";
|
|
332
|
+
if (task.backgroundedAt !== undefined) {
|
|
333
|
+
await this.options.runtimeSession.append({
|
|
334
|
+
type: "bash.task.stopping",
|
|
335
|
+
...task.origin,
|
|
336
|
+
data: { task: this.snapshot(task) },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
signalProcessGroup(task, "SIGTERM");
|
|
341
|
+
let escalated = false;
|
|
342
|
+
|
|
343
|
+
if (!(await completesWithin(task.completion, this.stopGraceMs))) {
|
|
344
|
+
this.synchronizeTerminalState(task);
|
|
345
|
+
if (!isTerminalStatus(task.status)) {
|
|
346
|
+
signalProcessGroup(task, "SIGKILL");
|
|
347
|
+
escalated = true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const snapshot = await task.completion;
|
|
352
|
+
return {
|
|
353
|
+
task: snapshot,
|
|
354
|
+
requestedSignal: "SIGTERM",
|
|
355
|
+
escalated,
|
|
356
|
+
reason,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async monitorTaskSafely(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
|
|
361
|
+
try {
|
|
362
|
+
return await this.monitorTask(task);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
task.status = "failed";
|
|
365
|
+
task.endedAt ??= new Date().toISOString();
|
|
366
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await task.output.end();
|
|
370
|
+
} catch (outputError) {
|
|
371
|
+
task.error = `${task.error}; output cleanup failed: ${
|
|
372
|
+
outputError instanceof Error ? outputError.message : String(outputError)
|
|
373
|
+
}`;
|
|
374
|
+
}
|
|
375
|
+
await unlinkIfExists(task.cwdFilePath);
|
|
376
|
+
|
|
377
|
+
return this.snapshot(task);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
|
|
382
|
+
const exit = waitForProcessExit(task.process);
|
|
383
|
+
const close = waitForProcessClose(task.process);
|
|
384
|
+
const result = await exit;
|
|
385
|
+
|
|
386
|
+
this.applyTermination(task, result);
|
|
387
|
+
await close;
|
|
388
|
+
await task.output.end();
|
|
389
|
+
await this.updateCwdFromFile(task);
|
|
390
|
+
await unlinkIfExists(task.cwdFilePath);
|
|
391
|
+
|
|
392
|
+
const snapshot = this.snapshot(task);
|
|
393
|
+
if (task.backgroundedAt !== undefined && !task.terminalEventEmitted) {
|
|
394
|
+
task.terminalEventEmitted = true;
|
|
395
|
+
await this.options.runtimeSession.append({
|
|
396
|
+
type: "bash.task.finished",
|
|
397
|
+
...task.origin,
|
|
398
|
+
data: { task: snapshot },
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return snapshot;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private applyTermination(task: ManagedShellTask, result: ProcessExitResult): void {
|
|
406
|
+
if (isTerminalStatus(task.status)) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
task.endedAt ??= new Date().toISOString();
|
|
411
|
+
if (result.error !== undefined) {
|
|
412
|
+
task.status = "failed";
|
|
413
|
+
task.error = result.error;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (result.signal !== null) {
|
|
418
|
+
task.status = "killed";
|
|
419
|
+
task.signal = result.signal;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
task.exitCode = result.code ?? 1;
|
|
424
|
+
task.status = task.exitCode === 0 ? "completed" : "failed";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private synchronizeTerminalState(task: ManagedShellTask): void {
|
|
428
|
+
if (isTerminalStatus(task.status)) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (task.process.signalCode !== null) {
|
|
433
|
+
this.applyTermination(task, {
|
|
434
|
+
code: null,
|
|
435
|
+
signal: task.process.signalCode,
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (task.process.exitCode !== null) {
|
|
441
|
+
this.applyTermination(task, {
|
|
442
|
+
code: task.process.exitCode,
|
|
443
|
+
signal: null,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private snapshot(task: ManagedShellTask): ShellTaskSnapshot {
|
|
449
|
+
const output = task.output.snapshot();
|
|
450
|
+
return {
|
|
451
|
+
taskId: task.id,
|
|
452
|
+
origin: task.origin,
|
|
453
|
+
command: task.command,
|
|
454
|
+
description: task.description,
|
|
455
|
+
status: task.status,
|
|
456
|
+
exitCode: task.exitCode,
|
|
457
|
+
signal: task.signal,
|
|
458
|
+
error: task.error,
|
|
459
|
+
startedAt: task.startedAt,
|
|
460
|
+
endedAt: task.endedAt,
|
|
461
|
+
backgroundedAt: task.backgroundedAt,
|
|
462
|
+
backgroundReason: task.backgroundReason,
|
|
463
|
+
outputFilePath: task.outputFilePath,
|
|
464
|
+
outputBytes: output.outputBytes,
|
|
465
|
+
outputLines: output.outputLines,
|
|
466
|
+
cwd: task.cwd,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private requireTask(taskId: string): ManagedShellTask {
|
|
471
|
+
const task = this.tasks.get(taskId);
|
|
472
|
+
if (task === undefined) {
|
|
473
|
+
throw new Error(`Unknown task ID: ${taskId}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return task;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private async updateCwdFromFile(task: ManagedShellTask): Promise<void> {
|
|
480
|
+
try {
|
|
481
|
+
const cwd = (await readFile(task.cwdFilePath, "utf8")).trim();
|
|
482
|
+
if (cwd !== "" && isWorkspaceLocalCwd(this.options.workspaceRoot, cwd)) {
|
|
483
|
+
task.cwd = cwd;
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
type ProcessExitResult = {
|
|
492
|
+
code: number | null;
|
|
493
|
+
signal: NodeJS.Signals | null;
|
|
494
|
+
error?: string;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
function waitForProcessExit(
|
|
498
|
+
process: ChildProcessWithoutNullStreams,
|
|
499
|
+
): Promise<ProcessExitResult> {
|
|
500
|
+
return new Promise((resolve) => {
|
|
501
|
+
let settled = false;
|
|
502
|
+
const finish = (result: ProcessExitResult) => {
|
|
503
|
+
if (!settled) {
|
|
504
|
+
settled = true;
|
|
505
|
+
resolve(result);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
process.once("error", (error) => {
|
|
510
|
+
finish({ code: null, signal: null, error: error.message });
|
|
511
|
+
});
|
|
512
|
+
process.once("exit", (code, signal) => {
|
|
513
|
+
finish({ code, signal });
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function waitForProcessError(process: ChildProcessWithoutNullStreams): Promise<Error> {
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
process.once("error", resolve);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function waitForProcessClose(process: ChildProcessWithoutNullStreams): Promise<void> {
|
|
525
|
+
return new Promise((resolve) => {
|
|
526
|
+
let settled = false;
|
|
527
|
+
const finish = () => {
|
|
528
|
+
if (!settled) {
|
|
529
|
+
settled = true;
|
|
530
|
+
resolve();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
process.once("error", finish);
|
|
535
|
+
process.once("close", finish);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function signalProcessGroup(
|
|
540
|
+
task: ManagedShellTask,
|
|
541
|
+
signal: "SIGTERM" | "SIGKILL",
|
|
542
|
+
): void {
|
|
543
|
+
try {
|
|
544
|
+
process.kill(-task.processGroupId, signal);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (!isNoSuchProcess(error)) {
|
|
547
|
+
throw error;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function completesWithin(
|
|
553
|
+
completion: Promise<ShellTaskSnapshot>,
|
|
554
|
+
timeoutMs: number,
|
|
555
|
+
): Promise<boolean> {
|
|
556
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
return await Promise.race([
|
|
560
|
+
completion.then(() => true),
|
|
561
|
+
new Promise<false>((resolve) => {
|
|
562
|
+
timeout = setTimeout(() => resolve(false), timeoutMs);
|
|
563
|
+
}),
|
|
564
|
+
]);
|
|
565
|
+
} finally {
|
|
566
|
+
if (timeout !== undefined) {
|
|
567
|
+
clearTimeout(timeout);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function isTerminalStatus(status: ShellTaskStatus): boolean {
|
|
573
|
+
return status === "completed" || status === "failed" || status === "killed";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function pipeTaskOutput(stream: NodeJS.ReadableStream, output: TaskOutput): void {
|
|
577
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
578
|
+
output.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function ensureEmptyFile(filePath: string): Promise<void> {
|
|
583
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
584
|
+
const file = await open(filePath, "w");
|
|
585
|
+
await file.close();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function unlinkIfExists(filePath: string): Promise<void> {
|
|
589
|
+
try {
|
|
590
|
+
await unlink(filePath);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (!isNotFound(error)) {
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isNotFound(error: unknown): boolean {
|
|
599
|
+
return errorCode(error) === "ENOENT";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function isNoSuchProcess(error: unknown): boolean {
|
|
603
|
+
return errorCode(error) === "ESRCH";
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function errorCode(error: unknown): unknown {
|
|
607
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
608
|
+
? error.code
|
|
609
|
+
: undefined;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const bashWrapperScript = `
|
|
613
|
+
eval "$TINKER_BASH_COMMAND"
|
|
614
|
+
exit_code=$?
|
|
615
|
+
pwd -P > "$TINKER_BASH_CWD_FILE"
|
|
616
|
+
exit "$exit_code"
|
|
617
|
+
`;
|