@yeaft/webchat-agent 0.1.1104 → 0.1.1107
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/cli.js +28 -17
- package/connection/index.js +6 -3
- package/connection/message-router.js +10 -6
- package/index.js +4 -2
- package/llm-config-cli.js +48 -0
- package/package.json +1 -1
- package/service/config.js +90 -15
- package/service/index.js +78 -40
- package/service/linux.js +48 -37
- package/service/macos.js +35 -30
- package/service/windows.js +46 -39
- package/service.js +10 -0
- package/terminal.js +14 -2
- package/yeaft/cli.js +7 -7
- package/yeaft/conversation/persist.js +28 -118
- package/yeaft/debug-trace.js +967 -695
- package/yeaft/engine.js +127 -96
- package/yeaft/llm/adapter.js +7 -3
- package/yeaft/llm/anthropic.js +56 -15
- package/yeaft/llm/openai-responses.js +22 -12
- package/yeaft/llm/router.js +16 -2
- package/yeaft/models.js +15 -2
- package/yeaft/session.js +9 -17
- package/yeaft/sessions/session-crud.js +42 -23
- package/yeaft/systemd-scope.js +88 -0
- package/yeaft/tasks/manager.js +81 -13
- package/yeaft/tasks/shell-runner.js +11 -3
- package/yeaft/tools/bash.js +9 -2
- package/yeaft/vp-status-broker.js +8 -3
- package/yeaft/web-bridge.js +221 -24
|
@@ -12,16 +12,18 @@
|
|
|
12
12
|
* Plus the D1 bootstrap helper:
|
|
13
13
|
* ensureDefaultSessionIfEmpty(yeaftDir, {libDir}) — if NO session exists on
|
|
14
14
|
* disk, seed `session_default` with roster = every VP in the library, and
|
|
15
|
-
* defaultVpId =
|
|
15
|
+
* defaultVpId = `omni` when present, otherwise the alphabetically first vpId.
|
|
16
|
+
* No-op when ≥1 session present.
|
|
16
17
|
*
|
|
17
18
|
* Hard constraints (PM):
|
|
18
19
|
* (a) We don't touch 334o storage primitives (storage/index.js) — we call
|
|
19
20
|
* group-store.openSession / saveMeta which already go through openLog.
|
|
20
21
|
* (b) We don't touch VP entity (vp-store.js / vp-loader.js) — only read
|
|
21
22
|
* via scanVpLibrary to know which VPs exist at seed time.
|
|
22
|
-
* (c)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* (c) `createSessionFromSpec` seeds omitted/empty rosters with the default
|
|
24
|
+
* generalist VP when the library has one; truly empty VP libraries can
|
|
25
|
+
* still create empty sessions and surface `no_default_vp` on first send.
|
|
26
|
+
* On `removeMember` we permit the empty state (UI nudges the user).
|
|
25
27
|
*
|
|
26
28
|
* Error shape — every throw is a `SessionCrudError` with a stable `.code`:
|
|
27
29
|
* 'not_found' — group id has no dir / meta
|
|
@@ -98,6 +100,7 @@ export class SessionCrudError extends Error {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
const GROUP_WORKDIR_REGISTRY = 'group-workdirs.json';
|
|
103
|
+
const DEFAULT_VP_ID = 'omni';
|
|
101
104
|
|
|
102
105
|
export function sessionsRoot(yeaftDir) {
|
|
103
106
|
return join(yeaftDir, 'sessions');
|
|
@@ -260,11 +263,24 @@ export function makeSessionId(name) {
|
|
|
260
263
|
return nextSessionId(slug);
|
|
261
264
|
}
|
|
262
265
|
|
|
266
|
+
function preferDefaultVp(vpIds) {
|
|
267
|
+
if (!Array.isArray(vpIds) || vpIds.length === 0) return null;
|
|
268
|
+
return vpIds.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : vpIds[0];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function scanSortedVpIds(libDir) {
|
|
272
|
+
const vpIds = scanVpLibrary({ dir: libDir })
|
|
273
|
+
.map(v => v && v.id)
|
|
274
|
+
.filter(v => typeof v === 'string' && v.length > 0);
|
|
275
|
+
vpIds.sort((a, b) => a.localeCompare(b));
|
|
276
|
+
return vpIds;
|
|
277
|
+
}
|
|
278
|
+
|
|
263
279
|
/**
|
|
264
280
|
* (B) D1 seed — called at boot (or when multi-VP is first enabled). Idempotent:
|
|
265
281
|
* returns `{seeded:false}` if any session already exists on disk (including
|
|
266
282
|
* `session_default`). When empty, seeds with roster = full VP library, sorted
|
|
267
|
-
* alphabetically; defaultVpId = roster[0].
|
|
283
|
+
* alphabetically; defaultVpId = `omni` when present, otherwise roster[0].
|
|
268
284
|
*
|
|
269
285
|
* When the VP library is also empty, we still seed an empty-roster session so
|
|
270
286
|
* the UI has somewhere to land — but defaultVpId is null and downstream
|
|
@@ -278,14 +294,12 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
278
294
|
return { seeded: false, sessionId: existing[0].id };
|
|
279
295
|
}
|
|
280
296
|
|
|
281
|
-
// Sort VP ids alphabetically (stable for tests / deterministic UI)
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
.filter(v => typeof v === 'string' && v.length > 0);
|
|
286
|
-
vps.sort((a, b) => a.localeCompare(b));
|
|
297
|
+
// Sort VP ids alphabetically (stable for tests / deterministic UI), but
|
|
298
|
+
// prefer the generalist Omni VP as the default when present so first-run
|
|
299
|
+
// sessions land on a useful assistant instead of an arbitrary first id.
|
|
300
|
+
const vps = scanSortedVpIds(libDir);
|
|
287
301
|
|
|
288
|
-
const defaultVpId = vps
|
|
302
|
+
const defaultVpId = preferDefaultVp(vps);
|
|
289
303
|
const { group, created } = seedDefaultSession(yeaftDir, {
|
|
290
304
|
name: options.name || 'Default',
|
|
291
305
|
roster: vps,
|
|
@@ -301,21 +315,27 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
301
315
|
}
|
|
302
316
|
|
|
303
317
|
/**
|
|
304
|
-
* (A.1) Create
|
|
305
|
-
*
|
|
318
|
+
* (A.1) Create session from a wizard spec. `spec.roster` is authoritative
|
|
319
|
+
* when non-empty. If the caller omits a roster, seed the session with the
|
|
320
|
+
* default generalist VP (`omni`) when it exists so a new Session is usable
|
|
321
|
+
* immediately instead of opening with an empty roster.
|
|
306
322
|
*
|
|
307
323
|
* @param {string} yeaftDir
|
|
308
324
|
* @param {{name:string, roster?:string[], defaultVpId?:string|null, workDir?:string}} spec
|
|
309
325
|
* @returns {{id:string, name:string, roster:string[], defaultVpId:string|null, workDir?:string}}
|
|
310
326
|
*/
|
|
311
327
|
export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
312
|
-
const
|
|
328
|
+
const input = spec || {};
|
|
329
|
+
const normalizedWorkDir = normalizeWorkDir(input.workDir);
|
|
313
330
|
const groupYeaftDir = normalizedWorkDir ? yeaftDirForWorkDir(normalizedWorkDir) : yeaftDir;
|
|
314
331
|
const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
|
|
315
|
-
const
|
|
332
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
333
|
+
const name = String(input.name || '').trim();
|
|
316
334
|
if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
|
|
317
335
|
|
|
318
|
-
const
|
|
336
|
+
const callerRoster = Array.isArray(input.roster) ? input.roster.slice() : [];
|
|
337
|
+
const fallbackVpId = callerRoster.length > 0 ? null : preferDefaultVp(scanSortedVpIds(libDir));
|
|
338
|
+
const roster = callerRoster.length > 0 ? callerRoster : (fallbackVpId ? [fallbackVpId] : []);
|
|
319
339
|
// Validate every member up-front so we fail before touching fs.
|
|
320
340
|
for (const vpId of roster) {
|
|
321
341
|
if (isReservedVpId(vpId)) {
|
|
@@ -325,10 +345,9 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
325
345
|
if (!v.ok) throw new SessionCrudError(v.reason, null, `invalid vpId: ${vpId}`);
|
|
326
346
|
}
|
|
327
347
|
|
|
328
|
-
// defaultVpId resolution: explicit > roster[0] > null. Null is
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
let defaultVpId = spec.defaultVpId || null;
|
|
348
|
+
// defaultVpId resolution: explicit > roster[0] > null. Null is only
|
|
349
|
+
// possible when both caller roster and VP library are empty.
|
|
350
|
+
let defaultVpId = input.defaultVpId || null;
|
|
332
351
|
if (defaultVpId && !roster.includes(defaultVpId)) {
|
|
333
352
|
throw new SessionCrudError('default_not_in_roster', null, `${defaultVpId} not in roster`);
|
|
334
353
|
}
|
|
@@ -354,8 +373,8 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
354
373
|
// turn.
|
|
355
374
|
try {
|
|
356
375
|
ensureSessionConfigFile(yeaftDir, id);
|
|
357
|
-
if (
|
|
358
|
-
saveSessionConfig(yeaftDir, id,
|
|
376
|
+
if (input.config && typeof input.config === 'object') {
|
|
377
|
+
saveSessionConfig(yeaftDir, id, input.config);
|
|
359
378
|
}
|
|
360
379
|
} catch (err) {
|
|
361
380
|
console.warn(`[session-crud] failed to seed config.json for ${id}:`, err?.message || err);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* systemd-scope.js — run child processes outside the agent service cgroup.
|
|
3
|
+
*
|
|
4
|
+
* When yeaft-agent runs as a systemd user service, shell tasks inherit the
|
|
5
|
+
* yeaft-agent.service cgroup by default. Long-lived commands then show up as
|
|
6
|
+
* "left-over process" entries every time the agent service restarts. Wrapping
|
|
7
|
+
* shell commands in a transient user scope keeps those user workloads alive
|
|
8
|
+
* without polluting the agent service lifecycle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import { delimiter, isAbsolute, join } from 'path';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SCOPE_PREFIX = 'yeaft-shell';
|
|
15
|
+
const UNIT_MAX_LENGTH = 180;
|
|
16
|
+
|
|
17
|
+
function hasPathSeparator(command) {
|
|
18
|
+
return command.includes('/') || command.includes('\\');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function findExecutableOnPath(command, env = process.env) {
|
|
22
|
+
if (!command || typeof command !== 'string') return null;
|
|
23
|
+
if (hasPathSeparator(command)) return existsSync(command) ? command : null;
|
|
24
|
+
|
|
25
|
+
const pathValue = env.PATH || '';
|
|
26
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
27
|
+
if (!dir) continue;
|
|
28
|
+
const candidate = isAbsolute(dir) ? join(dir, command) : join(process.cwd(), dir, command);
|
|
29
|
+
if (existsSync(candidate)) return candidate;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function shouldUseSystemdUserScope({ runtimePlatform, env = process.env, systemdRunPath = null } = {}) {
|
|
35
|
+
if (!runtimePlatform?.isLinux) return false;
|
|
36
|
+
if (env.YEAFT_DISABLE_SYSTEMD_SCOPE === '1') return false;
|
|
37
|
+
|
|
38
|
+
// INVOCATION_ID is set for systemd services and transient scopes. XDG_RUNTIME_DIR
|
|
39
|
+
// is required for `systemd-run --user` to talk to the user manager.
|
|
40
|
+
if (!env.INVOCATION_ID || !env.XDG_RUNTIME_DIR) return false;
|
|
41
|
+
|
|
42
|
+
const resolvedSystemdRun = systemdRunPath || findExecutableOnPath('systemd-run', env);
|
|
43
|
+
return !!resolvedSystemdRun;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sanitizeSystemdUnitPart(value) {
|
|
47
|
+
const raw = String(value || '').trim() || `${Date.now()}-${process.pid}`;
|
|
48
|
+
return raw
|
|
49
|
+
.replace(/[^A-Za-z0-9_.-]+/g, '-')
|
|
50
|
+
.replace(/^-+|-+$/g, '')
|
|
51
|
+
.slice(0, UNIT_MAX_LENGTH) || `${Date.now()}-${process.pid}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildSystemdScopeName(scopeId, prefix = DEFAULT_SCOPE_PREFIX) {
|
|
55
|
+
const safePrefix = sanitizeSystemdUnitPart(prefix).slice(0, 48);
|
|
56
|
+
const safeId = sanitizeSystemdUnitPart(scopeId);
|
|
57
|
+
const base = `${safePrefix}-${safeId}`.slice(0, UNIT_MAX_LENGTH);
|
|
58
|
+
return base.endsWith('.scope') ? base : `${base}.scope`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function wrapInvocationInSystemdUserScope(invocation, {
|
|
62
|
+
runtimePlatform,
|
|
63
|
+
env = process.env,
|
|
64
|
+
scopeId = null,
|
|
65
|
+
scopePrefix = DEFAULT_SCOPE_PREFIX,
|
|
66
|
+
systemdRunPath = null,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (!shouldUseSystemdUserScope({ runtimePlatform, env, systemdRunPath })) {
|
|
69
|
+
return { ...invocation, systemdScope: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const scopeName = buildSystemdScopeName(scopeId, scopePrefix);
|
|
73
|
+
return {
|
|
74
|
+
command: systemdRunPath || findExecutableOnPath('systemd-run', env) || 'systemd-run',
|
|
75
|
+
args: [
|
|
76
|
+
'--user',
|
|
77
|
+
'--scope',
|
|
78
|
+
'--quiet',
|
|
79
|
+
'--collect',
|
|
80
|
+
`--unit=${scopeName}`,
|
|
81
|
+
invocation.command,
|
|
82
|
+
...(invocation.args || []),
|
|
83
|
+
],
|
|
84
|
+
family: invocation.family,
|
|
85
|
+
systemdScope: scopeName,
|
|
86
|
+
wrappedCommand: invocation.command,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/yeaft/tasks/manager.js
CHANGED
|
@@ -11,6 +11,12 @@ import { startShellProcess } from './shell-runner.js';
|
|
|
11
11
|
import { getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
12
12
|
|
|
13
13
|
const LOG_PREVIEW_BYTES = 4096;
|
|
14
|
+
const SUB_AGENT_LOG_PREVIEW_BYTES = 1024 * 1024;
|
|
15
|
+
const DEFAULT_CANCEL_ESCALATION_MS = 2000;
|
|
16
|
+
|
|
17
|
+
function logPreviewBytesFor(task) {
|
|
18
|
+
return task?.kind === 'sub_agent' ? SUB_AGENT_LOG_PREVIEW_BYTES : LOG_PREVIEW_BYTES;
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
function nowIso() {
|
|
16
22
|
return new Date().toISOString();
|
|
@@ -40,14 +46,23 @@ function publicSnapshot(task) {
|
|
|
40
46
|
};
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
function taskCommand(task) {
|
|
50
|
+
const command = task?.runtime?.command;
|
|
51
|
+
return typeof command === 'string' && command.trim() ? command.trim() : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
export class TaskManager {
|
|
44
|
-
constructor({ yeaftDir, onEvent = null, runtimePlatform = null } = {}) {
|
|
55
|
+
constructor({ yeaftDir, onEvent = null, runtimePlatform = null, cancelEscalationMs = DEFAULT_CANCEL_ESCALATION_MS } = {}) {
|
|
45
56
|
if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
|
|
46
57
|
this.store = new TaskStore({ yeaftDir });
|
|
47
58
|
this.onEvent = typeof onEvent === 'function' ? onEvent : null;
|
|
48
59
|
this.runtimePlatform = runtimePlatform || getRuntimePlatformInfo();
|
|
60
|
+
this.cancelEscalationMs = Number.isFinite(cancelEscalationMs)
|
|
61
|
+
? Math.max(0, Math.floor(cancelEscalationMs))
|
|
62
|
+
: DEFAULT_CANCEL_ESCALATION_MS;
|
|
49
63
|
this.active = new Map();
|
|
50
64
|
this.processes = new Map();
|
|
65
|
+
this.cancelEscalationTimers = new Map();
|
|
51
66
|
this.#loadPersistedRunningTasks();
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -134,6 +149,7 @@ export class TaskManager {
|
|
|
134
149
|
command,
|
|
135
150
|
cwd,
|
|
136
151
|
pid: null,
|
|
152
|
+
systemdScope: null,
|
|
137
153
|
platform: (runtimePlatform || this.runtimePlatform)?.platform || process.platform,
|
|
138
154
|
},
|
|
139
155
|
log: {
|
|
@@ -155,6 +171,7 @@ export class TaskManager {
|
|
|
155
171
|
command,
|
|
156
172
|
cwd,
|
|
157
173
|
runtimePlatform: runtime,
|
|
174
|
+
scopeId: task.id,
|
|
158
175
|
onOutput: (stream, text) => {
|
|
159
176
|
const prefix = stream === 'stderr' ? '[stderr] ' : '';
|
|
160
177
|
this.store.appendLog(task.sessionId, task.id, prefix ? text.split(/(\n)/).map(part => part === '\n' ? part : (part ? `${prefix}${part}` : part)).join('') : text);
|
|
@@ -176,6 +193,7 @@ export class TaskManager {
|
|
|
176
193
|
});
|
|
177
194
|
|
|
178
195
|
task.runtime.pid = runner.pid;
|
|
196
|
+
task.runtime.systemdScope = runner.systemdScope || null;
|
|
179
197
|
this.processes.set(this.#key(task.sessionId, task.id), runner);
|
|
180
198
|
this.store.writeTask(task);
|
|
181
199
|
this.#emit('updated', task);
|
|
@@ -186,9 +204,15 @@ export class TaskManager {
|
|
|
186
204
|
const key = this.#key(sessionId, taskId);
|
|
187
205
|
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
188
206
|
if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
|
|
207
|
+
const escalationTimer = this.cancelEscalationTimers.get(key);
|
|
208
|
+
if (escalationTimer) {
|
|
209
|
+
clearTimeout(escalationTimer);
|
|
210
|
+
this.cancelEscalationTimers.delete(key);
|
|
211
|
+
}
|
|
189
212
|
const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
|
|
190
|
-
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes:
|
|
191
|
-
|
|
213
|
+
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: logPreviewBytesFor(task) });
|
|
214
|
+
const cancelRequested = !!task.runtime?.cancelRequestedAt;
|
|
215
|
+
task.status = cancelRequested ? TASK_STATUS.CANCELLED : (status || TASK_STATUS.FAILED);
|
|
192
216
|
task.updatedAt = nowIso();
|
|
193
217
|
task.endedAt = nowIso();
|
|
194
218
|
task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
|
|
@@ -207,19 +231,61 @@ export class TaskManager {
|
|
|
207
231
|
if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
|
|
208
232
|
if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
|
|
209
233
|
const runner = this.processes.get(key);
|
|
210
|
-
|
|
211
|
-
if (!killed) {
|
|
234
|
+
if (!runner) {
|
|
212
235
|
return {
|
|
213
236
|
ok: false,
|
|
214
|
-
error: 'Unable to cancel task: no live process handle
|
|
237
|
+
error: 'Unable to cancel task: no live process handle.',
|
|
215
238
|
task: publicSnapshot(task),
|
|
216
239
|
};
|
|
217
240
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
241
|
+
|
|
242
|
+
if (!task.runtime?.cancelRequestedAt) {
|
|
243
|
+
const signalled = runner.kill('SIGTERM');
|
|
244
|
+
if (!signalled) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: 'Unable to cancel task: process-tree signal failed.',
|
|
248
|
+
task: publicSnapshot(task),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const cancelRequestedAt = nowIso();
|
|
252
|
+
task.runtime = {
|
|
253
|
+
...(task.runtime || {}),
|
|
254
|
+
cancelRequestedAt,
|
|
255
|
+
cancelSignal: 'SIGTERM',
|
|
256
|
+
cancelEscalationMs: this.cancelEscalationMs,
|
|
257
|
+
};
|
|
258
|
+
task.updatedAt = cancelRequestedAt;
|
|
259
|
+
this.store.writeTask(task);
|
|
260
|
+
this.active.set(key, task);
|
|
261
|
+
this.store.appendEvent(sessionId, { event: 'cancel_requested', taskId, signal: 'SIGTERM' });
|
|
262
|
+
this.#emit('updated', task, { cancelRequested: true });
|
|
263
|
+
|
|
264
|
+
if (this.cancelEscalationMs >= 0 && !this.cancelEscalationTimers.has(key)) {
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
this.cancelEscalationTimers.delete(key);
|
|
267
|
+
const current = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
268
|
+
if (!current || isTerminalTaskStatus(current.status)) return;
|
|
269
|
+
const liveRunner = this.processes.get(key);
|
|
270
|
+
const escalated = liveRunner ? liveRunner.kill('SIGKILL') : false;
|
|
271
|
+
current.runtime = {
|
|
272
|
+
...(current.runtime || {}),
|
|
273
|
+
cancelEscalatedAt: nowIso(),
|
|
274
|
+
cancelEscalatedSignal: 'SIGKILL',
|
|
275
|
+
cancelEscalationFailed: !escalated,
|
|
276
|
+
};
|
|
277
|
+
current.updatedAt = current.runtime.cancelEscalatedAt;
|
|
278
|
+
this.store.writeTask(current);
|
|
279
|
+
this.active.set(key, current);
|
|
280
|
+
this.store.appendEvent(sessionId, { event: 'cancel_escalated', taskId, signal: 'SIGKILL', ok: escalated });
|
|
281
|
+
this.#emit('updated', current, { cancelEscalated: true, cancelEscalationOk: escalated });
|
|
282
|
+
}, this.cancelEscalationMs);
|
|
283
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
284
|
+
this.cancelEscalationTimers.set(key, timer);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { ok: true, task: publicSnapshot(task), pending: true };
|
|
223
289
|
}
|
|
224
290
|
|
|
225
291
|
listActiveTasks(sessionId = null) {
|
|
@@ -254,7 +320,7 @@ export class TaskManager {
|
|
|
254
320
|
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
255
321
|
if (!task) return null;
|
|
256
322
|
const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
|
|
257
|
-
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes:
|
|
323
|
+
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: logPreviewBytesFor(task) });
|
|
258
324
|
task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
|
|
259
325
|
task.updatedAt = nowIso();
|
|
260
326
|
this.store.writeTask(task);
|
|
@@ -269,7 +335,9 @@ export class TaskManager {
|
|
|
269
335
|
const lines = ['<active_tasks>'];
|
|
270
336
|
for (const task of tasks) {
|
|
271
337
|
const preview = (task.log?.preview || '').trim().split('\n').slice(-3).join(' | ');
|
|
272
|
-
|
|
338
|
+
const command = taskCommand(task);
|
|
339
|
+
const cancelRequestedAt = typeof task.runtime?.cancelRequestedAt === 'string' ? task.runtime.cancelRequestedAt : '';
|
|
340
|
+
lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)}${command ? ` | command=${JSON.stringify(command)}` : ''}${cancelRequestedAt ? ` | cancelRequestedAt=${JSON.stringify(cancelRequestedAt)}` : ''} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
|
|
273
341
|
}
|
|
274
342
|
lines.push('</active_tasks>');
|
|
275
343
|
return lines.join('\n');
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { spawn, spawnSync } from 'child_process';
|
|
6
6
|
import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
7
|
+
import { wrapInvocationInSystemdUserScope } from '../systemd-scope.js';
|
|
7
8
|
|
|
8
9
|
export function buildWindowsTaskkillArgs(pid) {
|
|
9
10
|
return ['/pid', String(pid), '/t', '/f'];
|
|
@@ -33,12 +34,18 @@ export function killShellProcessTree(pid, runtimePlatform, signal = 'SIGTERM') {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onExit, onError }) {
|
|
37
|
+
export function startShellProcess({ command, cwd, runtimePlatform, scopeId = null, onOutput, onExit, onError }) {
|
|
37
38
|
const platform = runtimePlatform || getRuntimePlatformInfo();
|
|
38
|
-
const
|
|
39
|
+
const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
|
|
40
|
+
const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
|
|
41
|
+
const invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
|
|
42
|
+
runtimePlatform: platform,
|
|
43
|
+
env,
|
|
44
|
+
scopeId,
|
|
45
|
+
});
|
|
39
46
|
const proc = spawn(invocation.command, invocation.args, {
|
|
40
47
|
cwd,
|
|
41
|
-
env
|
|
48
|
+
env,
|
|
42
49
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
50
|
detached: !platform.isWindows,
|
|
44
51
|
windowsHide: true,
|
|
@@ -57,6 +64,7 @@ export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onE
|
|
|
57
64
|
|
|
58
65
|
return {
|
|
59
66
|
pid: proc.pid || null,
|
|
67
|
+
systemdScope: invocation.systemdScope || null,
|
|
60
68
|
kill(signal = 'SIGTERM') {
|
|
61
69
|
return killShellProcessTree(proc.pid, platform, signal);
|
|
62
70
|
},
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawn } from 'child_process';
|
|
|
13
13
|
import { existsSync } from 'fs';
|
|
14
14
|
import { resolve } from 'path';
|
|
15
15
|
import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
16
|
+
import { wrapInvocationInSystemdUserScope } from '../systemd-scope.js';
|
|
16
17
|
|
|
17
18
|
export { buildShellInvocation };
|
|
18
19
|
|
|
@@ -32,10 +33,16 @@ const MAX_TIMEOUT_MS = 600_000;
|
|
|
32
33
|
function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
|
|
33
34
|
return new Promise((resolve) => {
|
|
34
35
|
const platform = runtimePlatform || getRuntimePlatformInfo();
|
|
35
|
-
const
|
|
36
|
+
const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
|
|
37
|
+
const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
|
|
38
|
+
const invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
|
|
39
|
+
runtimePlatform: platform,
|
|
40
|
+
env,
|
|
41
|
+
scopeId: `foreground-${Date.now()}-${process.pid}`,
|
|
42
|
+
});
|
|
36
43
|
const proc = spawn(invocation.command, invocation.args, {
|
|
37
44
|
cwd,
|
|
38
|
-
env
|
|
45
|
+
env,
|
|
39
46
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
47
|
detached: !platform.isWindows,
|
|
41
48
|
});
|
|
@@ -17,6 +17,11 @@ export const VALID_STATES = new Set([
|
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
19
|
const RUNNING_STATES = new Set(['typing', 'thinking', 'streaming', 'tool']);
|
|
20
|
+
|
|
21
|
+
export function isVpStatusRunning(state) {
|
|
22
|
+
return RUNNING_STATES.has(state || 'idle');
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
const STATE_PRIORITY = ['tool', 'streaming', 'thinking', 'typing', 'error', 'idle'];
|
|
21
26
|
const MAX_RETAINED_THREADS_PER_VP = 20;
|
|
22
27
|
const COMPLETED_TTL_MS = 30 * 60 * 1000;
|
|
@@ -52,7 +57,7 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
|
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
const runningThreadCount = rows.filter(r =>
|
|
60
|
+
const runningThreadCount = rows.filter(r => isVpStatusRunning(r.state)).length;
|
|
56
61
|
const latest = rows[0] || null;
|
|
57
62
|
return {
|
|
58
63
|
sessionId: sessionId || null,
|
|
@@ -83,14 +88,14 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
|
|
|
83
88
|
}
|
|
84
89
|
const cutoff = now() - COMPLETED_TTL_MS;
|
|
85
90
|
for (const [key, row] of rows) {
|
|
86
|
-
if (
|
|
91
|
+
if (isVpStatusRunning(row.state)) continue;
|
|
87
92
|
if ((row.updatedAt || row.since || 0) < cutoff) threads.delete(key);
|
|
88
93
|
}
|
|
89
94
|
const remaining = rows
|
|
90
95
|
.filter(([key]) => threads.has(key))
|
|
91
96
|
.sort((a, b) => (b[1].updatedAt || b[1].since || 0) - (a[1].updatedAt || a[1].since || 0));
|
|
92
97
|
for (const [key, row] of remaining.slice(MAX_RETAINED_THREADS_PER_VP)) {
|
|
93
|
-
if (!
|
|
98
|
+
if (!isVpStatusRunning(row.state)) threads.delete(key);
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|