@yeaft/webchat-agent 0.1.1065 → 0.1.1067
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/package.json +1 -1
- package/yeaft/conversation/persist.js +6 -0
- package/yeaft/engine.js +17 -2
- package/yeaft/prompts.js +4 -0
- package/yeaft/runtime-platform.js +29 -0
- package/yeaft/session.js +4 -0
- package/yeaft/sub-agent/runner.js +18 -0
- package/yeaft/tasks/manager.js +327 -0
- package/yeaft/tasks/shell-runner.js +64 -0
- package/yeaft/tasks/store.js +167 -0
- package/yeaft/tools/agent.js +10 -0
- package/yeaft/tools/bash.js +42 -29
- package/yeaft/tools/cancel-task.js +30 -0
- package/yeaft/tools/close-agent.js +8 -0
- package/yeaft/tools/index.js +6 -0
- package/yeaft/tools/list-tasks.js +29 -0
- package/yeaft/tools/read-task-log.js +38 -0
- package/yeaft/tools/types.js +3 -0
- package/yeaft/web-bridge.js +12 -0
package/package.json
CHANGED
|
@@ -186,6 +186,9 @@ function serializeMessage(msg) {
|
|
|
186
186
|
if (msg.model) fm.push(`model: ${msg.model}`);
|
|
187
187
|
if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
|
|
188
188
|
if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
|
|
189
|
+
if (msg.eventType) fm.push(`eventType: ${msg.eventType}`);
|
|
190
|
+
if (msg.taskId) fm.push(`taskId: ${msg.taskId}`);
|
|
191
|
+
if (msg.taskStatus) fm.push(`taskStatus: ${msg.taskStatus}`);
|
|
189
192
|
if (msg.isError) fm.push(`isError: true`);
|
|
190
193
|
// task-307: every message is stamped with a threadId so multi-thread
|
|
191
194
|
// routing can filter/replay by thread without rescanning JSON blobs.
|
|
@@ -312,6 +315,9 @@ export function parseMessage(raw) {
|
|
|
312
315
|
case 'model': msg.model = value; break;
|
|
313
316
|
case 'turnNumber': msg.turnNumber = parseInt(value, 10); break;
|
|
314
317
|
case 'toolCallId': msg.toolCallId = value; break;
|
|
318
|
+
case 'eventType': msg.eventType = value; break;
|
|
319
|
+
case 'taskId': msg.taskId = value; break;
|
|
320
|
+
case 'taskStatus': msg.taskStatus = value; break;
|
|
315
321
|
case 'isError': msg.isError = value === 'true'; break;
|
|
316
322
|
case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
|
|
317
323
|
case 'threadId': msg.threadId = value; break;
|
package/yeaft/engine.js
CHANGED
|
@@ -387,6 +387,9 @@ export class Engine {
|
|
|
387
387
|
/** @type {import('./tools/registry.js').ToolRegistry|null} */
|
|
388
388
|
#toolRegistry;
|
|
389
389
|
|
|
390
|
+
/** @type {import('./tasks/manager.js').TaskManager|null} */
|
|
391
|
+
#taskManager;
|
|
392
|
+
|
|
390
393
|
/** @type {import('./skills.js').SkillManager|null} */
|
|
391
394
|
#skillManager;
|
|
392
395
|
|
|
@@ -501,7 +504,7 @@ export class Engine {
|
|
|
501
504
|
* toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
|
|
502
505
|
* }} params
|
|
503
506
|
*/
|
|
504
|
-
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, sessionId = null, vpId = null, chatId = null }) {
|
|
507
|
+
constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, taskManager = null, sessionId = null, vpId = null, chatId = null }) {
|
|
505
508
|
this.#adapter = adapter;
|
|
506
509
|
this.#trace = trace;
|
|
507
510
|
this.#config = config;
|
|
@@ -511,6 +514,7 @@ export class Engine {
|
|
|
511
514
|
this.#memoryIndex = memoryIndex || null;
|
|
512
515
|
this.#amsRegistry = amsRegistry || null;
|
|
513
516
|
this.#toolRegistry = toolRegistry || null;
|
|
517
|
+
this.#taskManager = taskManager || null;
|
|
514
518
|
this.#skillManager = skillManager || null;
|
|
515
519
|
this.#mcpManager = mcpManager || null;
|
|
516
520
|
this.#yeaftDir = yeaftDir || null;
|
|
@@ -870,7 +874,7 @@ export class Engine {
|
|
|
870
874
|
* @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
|
|
871
875
|
* @returns {string}
|
|
872
876
|
*/
|
|
873
|
-
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx } = {}) {
|
|
877
|
+
#buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks } = {}) {
|
|
874
878
|
// Get relevant skill content if SkillManager is wired
|
|
875
879
|
let skillContent = '';
|
|
876
880
|
if (this.#skillManager && prompt) {
|
|
@@ -893,6 +897,7 @@ export class Engine {
|
|
|
893
897
|
projectDoc,
|
|
894
898
|
runtimePlatform: getRuntimePlatformInfo(),
|
|
895
899
|
taskCtx,
|
|
900
|
+
activeTasks,
|
|
896
901
|
// Worker-shape harness is descriptive metadata for human inspection;
|
|
897
902
|
// production prompts skip it to save tokens. Re-enable via env when
|
|
898
903
|
// diagnosing prompt structure issues.
|
|
@@ -997,6 +1002,10 @@ export class Engine {
|
|
|
997
1002
|
conversationStore: this.#conversationStore,
|
|
998
1003
|
adapter: this.#adapter,
|
|
999
1004
|
config: this.#config,
|
|
1005
|
+
taskManager: this.#taskManager,
|
|
1006
|
+
sessionId: vpCtx?.sessionId || this.#sessionId || null,
|
|
1007
|
+
threadId: vpCtx?.threadId || this.#currentThreadId || MAIN_THREAD_ID,
|
|
1008
|
+
currentVpId: vpCtx?.senderVpId || this.#vpId || null,
|
|
1000
1009
|
// task-704b: per-tool-result hard cap derives from this. Threaded
|
|
1001
1010
|
// from the live model (resolveModel(currentModel)) every turn so
|
|
1002
1011
|
// fallbackModel switches see the new window. Falls back to
|
|
@@ -1047,6 +1056,7 @@ export class Engine {
|
|
|
1047
1056
|
parentVpId: vpCtx?.senderVpId || null,
|
|
1048
1057
|
parentVpPersona: vpCtx?.vpPersona || null,
|
|
1049
1058
|
parentSessionId: vpCtx?.sessionId || null,
|
|
1059
|
+
parentThreadId: vpCtx?.threadId || this.#currentThreadId || MAIN_THREAD_ID,
|
|
1050
1060
|
onEvent: this.#subAgentEventSink || null,
|
|
1051
1061
|
language: this.#config?.language || 'en',
|
|
1052
1062
|
// Forward the session-shared ToolUsageStats so sub-agent
|
|
@@ -1054,6 +1064,7 @@ export class Engine {
|
|
|
1054
1064
|
// (~/.yeaft/stats/tool-usage.json) the parent engine writes
|
|
1055
1065
|
// to. Null when the parent has no stats wired (e.g. tests).
|
|
1056
1066
|
toolStats: this.#toolStats || null,
|
|
1067
|
+
taskManager: this.#taskManager || null,
|
|
1057
1068
|
},
|
|
1058
1069
|
};
|
|
1059
1070
|
}
|
|
@@ -1614,6 +1625,9 @@ export class Engine {
|
|
|
1614
1625
|
};
|
|
1615
1626
|
|
|
1616
1627
|
const projectDoc = this.#getProjectDocBlock(workDir);
|
|
1628
|
+
const activeTasks = this.#taskManager
|
|
1629
|
+
? this.#taskManager.renderActiveTasksForPrompt(runtimeSessionId)
|
|
1630
|
+
: '';
|
|
1617
1631
|
|
|
1618
1632
|
const systemPrompt = this.#buildSystemPrompt({
|
|
1619
1633
|
prompt,
|
|
@@ -1622,6 +1636,7 @@ export class Engine {
|
|
|
1622
1636
|
activeScope,
|
|
1623
1637
|
sessionAnnouncement,
|
|
1624
1638
|
projectDoc,
|
|
1639
|
+
activeTasks,
|
|
1625
1640
|
});
|
|
1626
1641
|
|
|
1627
1642
|
// ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
|
package/yeaft/prompts.js
CHANGED
|
@@ -309,6 +309,7 @@ export function buildSystemPrompt({
|
|
|
309
309
|
sessionAnnouncement = '',
|
|
310
310
|
projectDoc = '',
|
|
311
311
|
runtimePlatform,
|
|
312
|
+
activeTasks = '',
|
|
312
313
|
} = {}) {
|
|
313
314
|
// Normalize app locales like `zh-CN` to prompt dictionary/template keys.
|
|
314
315
|
const effectiveLang = normalizePromptLanguage(language);
|
|
@@ -411,6 +412,9 @@ export function buildSystemPrompt({
|
|
|
411
412
|
const activeScopeBlock = renderActiveScope(activeScope, lang);
|
|
412
413
|
if (activeScopeBlock) parts.push(activeScopeBlock);
|
|
413
414
|
|
|
415
|
+
const activeTaskText = typeof activeTasks === 'string' ? activeTasks.trim() : '';
|
|
416
|
+
if (activeTaskText) parts.push(activeTaskText);
|
|
417
|
+
|
|
414
418
|
const multiVpRoutingBlock = renderMultiVpRouting(activeScope, lang);
|
|
415
419
|
if (multiVpRoutingBlock) parts.push(multiVpRoutingBlock);
|
|
416
420
|
|
|
@@ -51,6 +51,35 @@ export function resolveDefaultShell(opts = {}) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Build a platform-specific shell invocation for executing a command string.
|
|
56
|
+
* Kept in runtime-platform so tools and task runners share OS behavior without
|
|
57
|
+
* depending on each other.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} command
|
|
60
|
+
* @param {{ runtimePlatform?: object }} opts
|
|
61
|
+
*/
|
|
62
|
+
export function buildShellInvocation(command, opts = {}) {
|
|
63
|
+
const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
|
|
64
|
+
const shell = runtimePlatform.defaultShell
|
|
65
|
+
? {
|
|
66
|
+
command: runtimePlatform.defaultShell,
|
|
67
|
+
argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
|
|
68
|
+
family: runtimePlatform.shellFamily,
|
|
69
|
+
}
|
|
70
|
+
: resolveDefaultShell({ platform: runtimePlatform.platform });
|
|
71
|
+
|
|
72
|
+
const argsPrefix = Array.isArray(shell.argsPrefix)
|
|
73
|
+
? shell.argsPrefix
|
|
74
|
+
: resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
command: shell.command,
|
|
78
|
+
args: [...argsPrefix, command],
|
|
79
|
+
family: shell.family || runtimePlatform.shellFamily || 'posix',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
/**
|
|
55
84
|
* @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
56
85
|
*/
|
package/yeaft/session.js
CHANGED
|
@@ -26,6 +26,7 @@ import { Engine } from './engine.js';
|
|
|
26
26
|
import { Compactor } from './compact/compactor.js';
|
|
27
27
|
import { resolveContextWindow } from './models.js';
|
|
28
28
|
import { ToolUsageStats } from './stats/tool-usage.js';
|
|
29
|
+
import { TaskManager } from './tasks/manager.js';
|
|
29
30
|
// H2.f.5 removed the old user-facing thread pipeline/dispatcher. The base
|
|
30
31
|
// session still exposes a single default Engine; PR #797 adds group VP thread
|
|
31
32
|
// engines in web-bridge runtime state, keyed below the session layer.
|
|
@@ -399,6 +400,7 @@ export async function loadSession(options = {}) {
|
|
|
399
400
|
}
|
|
400
401
|
|
|
401
402
|
// ─── 8. Build tool registry ────────────────────────────
|
|
403
|
+
const taskManager = new TaskManager({ yeaftDir, conversationStore });
|
|
402
404
|
const toolRegistry = createFullRegistry();
|
|
403
405
|
|
|
404
406
|
// Register any extra tools from caller
|
|
@@ -456,6 +458,7 @@ export async function loadSession(options = {}) {
|
|
|
456
458
|
mcpManager,
|
|
457
459
|
yeaftDir,
|
|
458
460
|
toolStats,
|
|
461
|
+
taskManager,
|
|
459
462
|
});
|
|
460
463
|
|
|
461
464
|
// ─── 9a-pre. Create per-group history Compactor ────────
|
|
@@ -608,6 +611,7 @@ export async function loadSession(options = {}) {
|
|
|
608
611
|
status,
|
|
609
612
|
amsRegistry,
|
|
610
613
|
toolStats,
|
|
614
|
+
taskManager,
|
|
611
615
|
shutdown,
|
|
612
616
|
// task-325c: user-initiated abort API. Delegates to web-bridge which
|
|
613
617
|
// owns the single AbortController. Lazy-imported to avoid a hard cycle
|
|
@@ -154,6 +154,7 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
154
154
|
mcpManager: deps.mcpManager || null,
|
|
155
155
|
yeaftDir: deps.yeaftDir || null,
|
|
156
156
|
toolStats: deps.toolStats || null,
|
|
157
|
+
taskManager: deps.taskManager || null,
|
|
157
158
|
});
|
|
158
159
|
|
|
159
160
|
agent.subEngine = subEngine;
|
|
@@ -164,6 +165,9 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
164
165
|
agent.parentThreadId = deps.parentThreadId || 'main';
|
|
165
166
|
agent.outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
|
|
166
167
|
agent.outputFile = agent.outputLog.path;
|
|
168
|
+
if (agent.taskId && deps.taskManager && agent.parentSessionId) {
|
|
169
|
+
try { deps.taskManager.setTaskLogPath(agent.parentSessionId, agent.taskId, agent.outputFile); } catch { /* ignore */ }
|
|
170
|
+
}
|
|
167
171
|
agent.outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
|
|
168
172
|
|
|
169
173
|
// Compose the system-prompt-overlay we want injected.
|
|
@@ -268,6 +272,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
268
272
|
const emit = (evt) => {
|
|
269
273
|
const wrapped = wrapEvt(evt);
|
|
270
274
|
try { agent.outputLog?.write(wrapped); } catch { /* ignore log failures */ }
|
|
275
|
+
if (agent.taskId && deps.taskManager && agent.parentSessionId) {
|
|
276
|
+
try { deps.taskManager.refreshTaskLog(agent.parentSessionId, agent.taskId); } catch { /* ignore */ }
|
|
277
|
+
}
|
|
271
278
|
if (onEvent) {
|
|
272
279
|
try { onEvent(agent.id, wrapped); } catch { /* ignore listener errors */ }
|
|
273
280
|
}
|
|
@@ -497,6 +504,17 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
|
|
|
497
504
|
error: error || agent.error || null,
|
|
498
505
|
};
|
|
499
506
|
try { agent.outputLog?.write(evt); } catch { /* ignore */ }
|
|
507
|
+
if (agent.taskId && deps?.taskManager && agent.parentSessionId) {
|
|
508
|
+
const taskStatus = status === STATUS.COMPLETED ? 'succeeded'
|
|
509
|
+
: status === STATUS.CLOSED ? 'cancelled'
|
|
510
|
+
: 'failed';
|
|
511
|
+
try {
|
|
512
|
+
deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
|
|
513
|
+
status: taskStatus,
|
|
514
|
+
error: error || agent.error || null,
|
|
515
|
+
});
|
|
516
|
+
} catch { /* ignore */ }
|
|
517
|
+
}
|
|
500
518
|
if (deps && typeof deps.onEvent === 'function') {
|
|
501
519
|
try { deps.onEvent(agent.id, evt); } catch { /* ignore */ }
|
|
502
520
|
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manager.js — Session-scoped background task manager.
|
|
3
|
+
*
|
|
4
|
+
* First-class tasks cover shell background commands today and provide the
|
|
5
|
+
* shared model that sub-agents can attach to next.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
import { TaskStore, TASK_STATUS, isTerminalTaskStatus } from './store.js';
|
|
10
|
+
import { startShellProcess } from './shell-runner.js';
|
|
11
|
+
import { getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
12
|
+
|
|
13
|
+
const LOG_PREVIEW_BYTES = 4096;
|
|
14
|
+
|
|
15
|
+
function nowIso() {
|
|
16
|
+
return new Date().toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeTaskId() {
|
|
20
|
+
return `task_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function publicSnapshot(task) {
|
|
24
|
+
if (!task) return null;
|
|
25
|
+
return {
|
|
26
|
+
id: task.id,
|
|
27
|
+
sessionId: task.sessionId,
|
|
28
|
+
ownerVpId: task.ownerVpId || null,
|
|
29
|
+
kind: task.kind,
|
|
30
|
+
title: task.title,
|
|
31
|
+
status: task.status,
|
|
32
|
+
createdAt: task.createdAt,
|
|
33
|
+
startedAt: task.startedAt,
|
|
34
|
+
updatedAt: task.updatedAt,
|
|
35
|
+
endedAt: task.endedAt || null,
|
|
36
|
+
runtime: task.runtime || {},
|
|
37
|
+
log: task.log || {},
|
|
38
|
+
result: task.result || {},
|
|
39
|
+
source: task.source || {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatTaskMessage(kind, task, extra = {}) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
const label = kind === 'started' ? 'Task started' : 'Task finished';
|
|
46
|
+
lines.push(`[${label}]`);
|
|
47
|
+
lines.push(`taskId: ${task.id}`);
|
|
48
|
+
lines.push(`kind: ${task.kind}`);
|
|
49
|
+
lines.push(`status: ${task.status}`);
|
|
50
|
+
lines.push(`ownerVpId: ${task.ownerVpId || 'unknown'}`);
|
|
51
|
+
lines.push(`title: ${task.title}`);
|
|
52
|
+
if (task.runtime?.command) lines.push(`command: ${task.runtime.command}`);
|
|
53
|
+
if (task.runtime?.cwd) lines.push(`cwd: ${task.runtime.cwd}`);
|
|
54
|
+
if (task.log?.path) lines.push(`log: ${task.log.path}`);
|
|
55
|
+
if (extra.exitCode != null) lines.push(`exitCode: ${extra.exitCode}`);
|
|
56
|
+
if (extra.signal) lines.push(`signal: ${extra.signal}`);
|
|
57
|
+
if (extra.summary) lines.push(`summary: ${extra.summary}`);
|
|
58
|
+
if (extra.logTail) {
|
|
59
|
+
lines.push('logTail:');
|
|
60
|
+
lines.push(extra.logTail.trimEnd());
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class TaskManager {
|
|
66
|
+
constructor({ yeaftDir, conversationStore = null, onEvent = null, runtimePlatform = null } = {}) {
|
|
67
|
+
if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
|
|
68
|
+
this.store = new TaskStore({ yeaftDir });
|
|
69
|
+
this.conversationStore = conversationStore;
|
|
70
|
+
this.onEvent = typeof onEvent === 'function' ? onEvent : null;
|
|
71
|
+
this.runtimePlatform = runtimePlatform || getRuntimePlatformInfo();
|
|
72
|
+
this.active = new Map();
|
|
73
|
+
this.processes = new Map();
|
|
74
|
+
this.#loadPersistedRunningTasks();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setEventSink(onEvent) {
|
|
78
|
+
this.onEvent = typeof onEvent === 'function' ? onEvent : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#key(sessionId, taskId) {
|
|
82
|
+
return `${sessionId || 'default'}::${taskId}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#emit(event, task, extra = {}) {
|
|
86
|
+
const payload = { type: 'yeaft_task_event', event, task: publicSnapshot(task), ...extra };
|
|
87
|
+
try { this.onEvent?.(payload); } catch { /* event sinks must not break tasks */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#persistLifecycleMessage(task, kind, extra = {}) {
|
|
91
|
+
if (!this.conversationStore || !task?.sessionId) return;
|
|
92
|
+
try {
|
|
93
|
+
this.conversationStore.append({
|
|
94
|
+
role: 'assistant',
|
|
95
|
+
content: formatTaskMessage(kind, task, extra),
|
|
96
|
+
sessionId: task.sessionId,
|
|
97
|
+
threadId: task.source?.threadId || 'main',
|
|
98
|
+
...(task.ownerVpId ? { speakerVpId: task.ownerVpId } : {}),
|
|
99
|
+
eventType: 'task_lifecycle',
|
|
100
|
+
taskId: task.id,
|
|
101
|
+
taskStatus: task.status,
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
// Conversation persistence must not kill the background process.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#loadPersistedRunningTasks() {
|
|
109
|
+
for (const task of this.store.loadActiveTasks()) {
|
|
110
|
+
const orphaned = {
|
|
111
|
+
...task,
|
|
112
|
+
status: TASK_STATUS.ORPHANED,
|
|
113
|
+
updatedAt: nowIso(),
|
|
114
|
+
endedAt: nowIso(),
|
|
115
|
+
result: {
|
|
116
|
+
...(task.result || {}),
|
|
117
|
+
error: 'Agent restarted while task was running; process control was lost.',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
this.store.writeTask(orphaned);
|
|
121
|
+
this.store.appendEvent(orphaned.sessionId, { event: 'orphaned', taskId: orphaned.id });
|
|
122
|
+
this.#persistLifecycleMessage(orphaned, 'finished', { summary: orphaned.result.error });
|
|
123
|
+
this.#emit('completed', orphaned);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
startTask({ sessionId, ownerVpId = null, kind = 'tool', title = '', runtime = {}, source = {}, logPath = null } = {}) {
|
|
128
|
+
const taskId = makeTaskId();
|
|
129
|
+
const resolvedSessionId = sessionId || 'default';
|
|
130
|
+
const task = {
|
|
131
|
+
id: taskId,
|
|
132
|
+
sessionId: resolvedSessionId,
|
|
133
|
+
ownerVpId,
|
|
134
|
+
kind,
|
|
135
|
+
title: title || kind,
|
|
136
|
+
status: TASK_STATUS.RUNNING,
|
|
137
|
+
createdAt: nowIso(),
|
|
138
|
+
startedAt: nowIso(),
|
|
139
|
+
updatedAt: nowIso(),
|
|
140
|
+
endedAt: null,
|
|
141
|
+
source,
|
|
142
|
+
runtime,
|
|
143
|
+
log: {
|
|
144
|
+
path: logPath || this.store.logPath(resolvedSessionId, taskId),
|
|
145
|
+
bytes: 0,
|
|
146
|
+
preview: '',
|
|
147
|
+
},
|
|
148
|
+
result: {},
|
|
149
|
+
};
|
|
150
|
+
this.store.writeTask(task);
|
|
151
|
+
this.store.appendEvent(task.sessionId, { event: 'started', taskId: task.id, kind: task.kind });
|
|
152
|
+
this.active.set(this.#key(task.sessionId, task.id), task);
|
|
153
|
+
this.#persistLifecycleMessage(task, 'started');
|
|
154
|
+
this.#emit('started', task);
|
|
155
|
+
return publicSnapshot(task);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
completeTask(sessionId, taskId, opts = {}) {
|
|
159
|
+
return this.#completeTask(sessionId, taskId, opts);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
startShellTask({ command, cwd, sessionId, ownerVpId = null, title = '', source = {}, runtimePlatform = null } = {}) {
|
|
163
|
+
if (!command || typeof command !== 'string') throw new Error('command is required');
|
|
164
|
+
const task = {
|
|
165
|
+
id: makeTaskId(),
|
|
166
|
+
sessionId: sessionId || 'default',
|
|
167
|
+
ownerVpId,
|
|
168
|
+
kind: 'shell',
|
|
169
|
+
title: title || command.slice(0, 120),
|
|
170
|
+
status: TASK_STATUS.RUNNING,
|
|
171
|
+
createdAt: nowIso(),
|
|
172
|
+
startedAt: nowIso(),
|
|
173
|
+
updatedAt: nowIso(),
|
|
174
|
+
endedAt: null,
|
|
175
|
+
source,
|
|
176
|
+
runtime: {
|
|
177
|
+
command,
|
|
178
|
+
cwd,
|
|
179
|
+
pid: null,
|
|
180
|
+
platform: (runtimePlatform || this.runtimePlatform)?.platform || process.platform,
|
|
181
|
+
},
|
|
182
|
+
log: {
|
|
183
|
+
path: this.store.logPath(sessionId || 'default', 'pending'),
|
|
184
|
+
bytes: 0,
|
|
185
|
+
preview: '',
|
|
186
|
+
},
|
|
187
|
+
result: {},
|
|
188
|
+
};
|
|
189
|
+
task.log.path = this.store.logPath(task.sessionId, task.id);
|
|
190
|
+
|
|
191
|
+
this.store.writeTask(task);
|
|
192
|
+
this.store.appendEvent(task.sessionId, { event: 'started', taskId: task.id, kind: task.kind });
|
|
193
|
+
this.active.set(this.#key(task.sessionId, task.id), task);
|
|
194
|
+
this.#persistLifecycleMessage(task, 'started');
|
|
195
|
+
this.#emit('started', task);
|
|
196
|
+
|
|
197
|
+
const runtime = runtimePlatform || this.runtimePlatform;
|
|
198
|
+
const runner = startShellProcess({
|
|
199
|
+
command,
|
|
200
|
+
cwd,
|
|
201
|
+
runtimePlatform: runtime,
|
|
202
|
+
onOutput: (stream, text) => {
|
|
203
|
+
const prefix = stream === 'stderr' ? '[stderr] ' : '';
|
|
204
|
+
this.store.appendLog(task.sessionId, task.id, prefix ? text.split(/(\n)/).map(part => part === '\n' ? part : (part ? `${prefix}${part}` : part)).join('') : text);
|
|
205
|
+
this.refreshTaskLog(task.sessionId, task.id);
|
|
206
|
+
},
|
|
207
|
+
onExit: ({ code, signal }) => {
|
|
208
|
+
this.#completeTask(task.sessionId, task.id, {
|
|
209
|
+
status: code === 0 ? TASK_STATUS.SUCCEEDED : TASK_STATUS.FAILED,
|
|
210
|
+
exitCode: code,
|
|
211
|
+
signal,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
onError: (err) => {
|
|
215
|
+
this.#completeTask(task.sessionId, task.id, {
|
|
216
|
+
status: TASK_STATUS.FAILED,
|
|
217
|
+
error: err?.message || String(err),
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
task.runtime.pid = runner.pid;
|
|
223
|
+
this.processes.set(this.#key(task.sessionId, task.id), runner);
|
|
224
|
+
this.store.writeTask(task);
|
|
225
|
+
this.#emit('updated', task);
|
|
226
|
+
return publicSnapshot(task);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#completeTask(sessionId, taskId, { status, exitCode = null, signal = null, error = null } = {}) {
|
|
230
|
+
const key = this.#key(sessionId, taskId);
|
|
231
|
+
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
232
|
+
if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
|
|
233
|
+
const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
|
|
234
|
+
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
|
|
235
|
+
task.status = status || TASK_STATUS.FAILED;
|
|
236
|
+
task.updatedAt = nowIso();
|
|
237
|
+
task.endedAt = nowIso();
|
|
238
|
+
task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
|
|
239
|
+
task.result = { ...(task.result || {}), exitCode, signal, error };
|
|
240
|
+
this.store.writeTask(task);
|
|
241
|
+
this.store.appendEvent(sessionId, { event: 'completed', taskId, status: task.status, exitCode, signal, error });
|
|
242
|
+
this.active.delete(key);
|
|
243
|
+
this.processes.delete(key);
|
|
244
|
+
this.#persistLifecycleMessage(task, 'finished', {
|
|
245
|
+
exitCode,
|
|
246
|
+
signal,
|
|
247
|
+
summary: error || `Task ${task.status}`,
|
|
248
|
+
logTail: tail.text,
|
|
249
|
+
});
|
|
250
|
+
this.#emit('completed', task);
|
|
251
|
+
return publicSnapshot(task);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
cancelTask(sessionId, taskId) {
|
|
255
|
+
const key = this.#key(sessionId, taskId);
|
|
256
|
+
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
257
|
+
if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
|
|
258
|
+
if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
|
|
259
|
+
const runner = this.processes.get(key);
|
|
260
|
+
const killed = runner ? runner.kill('SIGTERM') : false;
|
|
261
|
+
if (!killed) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: 'Unable to cancel task: no live process handle or process-tree kill failed.',
|
|
265
|
+
task: publicSnapshot(task),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const completed = this.#completeTask(sessionId, taskId, {
|
|
269
|
+
status: TASK_STATUS.CANCELLED,
|
|
270
|
+
signal: 'SIGTERM',
|
|
271
|
+
});
|
|
272
|
+
return { ok: true, task: completed };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
listActiveTasks(sessionId = null) {
|
|
276
|
+
const tasks = Array.from(this.active.values()).filter(task => !sessionId || task.sessionId === sessionId);
|
|
277
|
+
return tasks.map(publicSnapshot);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
getTask(sessionId, taskId) {
|
|
281
|
+
return publicSnapshot(this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
readTaskLog(sessionId, taskId, opts = {}) {
|
|
285
|
+
const task = this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId);
|
|
286
|
+
if (task?.log?.path) return this.store.readLogFile(task.log.path, opts);
|
|
287
|
+
return this.store.readLog(sessionId, taskId, opts);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setTaskLogPath(sessionId, taskId, logPath) {
|
|
291
|
+
if (!logPath || typeof logPath !== 'string') return null;
|
|
292
|
+
const key = this.#key(sessionId, taskId);
|
|
293
|
+
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
294
|
+
if (!task) return null;
|
|
295
|
+
task.log = { ...(task.log || {}), path: logPath };
|
|
296
|
+
task.updatedAt = nowIso();
|
|
297
|
+
this.store.writeTask(task);
|
|
298
|
+
if (!isTerminalTaskStatus(task.status)) this.active.set(key, task);
|
|
299
|
+
return this.refreshTaskLog(sessionId, taskId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
refreshTaskLog(sessionId, taskId) {
|
|
303
|
+
const key = this.#key(sessionId, taskId);
|
|
304
|
+
const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
|
|
305
|
+
if (!task) return null;
|
|
306
|
+
const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
|
|
307
|
+
const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
|
|
308
|
+
task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
|
|
309
|
+
task.updatedAt = nowIso();
|
|
310
|
+
this.store.writeTask(task);
|
|
311
|
+
if (!isTerminalTaskStatus(task.status)) this.active.set(key, task);
|
|
312
|
+
this.#emit('updated', task);
|
|
313
|
+
return publicSnapshot(task);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
renderActiveTasksForPrompt(sessionId = null) {
|
|
317
|
+
const tasks = this.listActiveTasks(sessionId);
|
|
318
|
+
if (tasks.length === 0) return '';
|
|
319
|
+
const lines = ['<active_tasks>'];
|
|
320
|
+
for (const task of tasks) {
|
|
321
|
+
const preview = (task.log?.preview || '').trim().split('\n').slice(-3).join(' | ');
|
|
322
|
+
lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
|
|
323
|
+
}
|
|
324
|
+
lines.push('</active_tasks>');
|
|
325
|
+
return lines.join('\n');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell-runner.js — Cross-platform background shell task runner.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn, spawnSync } from 'child_process';
|
|
6
|
+
import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
7
|
+
|
|
8
|
+
export function buildWindowsTaskkillArgs(pid) {
|
|
9
|
+
return ['/pid', String(pid), '/t', '/f'];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function killShellProcessTree(pid, runtimePlatform, signal = 'SIGTERM') {
|
|
13
|
+
if (!pid) return false;
|
|
14
|
+
const platform = runtimePlatform || getRuntimePlatformInfo();
|
|
15
|
+
if (platform.isWindows) {
|
|
16
|
+
const result = spawnSync('taskkill.exe', buildWindowsTaskkillArgs(pid), {
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
stdio: 'ignore',
|
|
19
|
+
});
|
|
20
|
+
return result.status === 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
process.kill(-pid, signal);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, signal);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onExit, onError }) {
|
|
37
|
+
const platform = runtimePlatform || getRuntimePlatformInfo();
|
|
38
|
+
const invocation = buildShellInvocation(command, { runtimePlatform: platform });
|
|
39
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
40
|
+
cwd,
|
|
41
|
+
env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
detached: !platform.isWindows,
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const write = (stream, chunk) => {
|
|
48
|
+
if (!chunk) return;
|
|
49
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
50
|
+
onOutput?.(stream, text);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
proc.stdout?.on('data', (chunk) => write('stdout', chunk));
|
|
54
|
+
proc.stderr?.on('data', (chunk) => write('stderr', chunk));
|
|
55
|
+
proc.on('error', (err) => onError?.(err));
|
|
56
|
+
proc.on('close', (code, signal) => onExit?.({ code, signal }));
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
pid: proc.pid || null,
|
|
60
|
+
kill(signal = 'SIGTERM') {
|
|
61
|
+
return killShellProcessTree(proc.pid, platform, signal);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js — Persistent Session task metadata store.
|
|
3
|
+
*
|
|
4
|
+
* Tasks are Session-scoped runtime facts. Metadata is stored separately from
|
|
5
|
+
* logs so a noisy process cannot corrupt the task index.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
appendFileSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
readSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
|
|
22
|
+
export const TASK_STATUS = Object.freeze({
|
|
23
|
+
RUNNING: 'running',
|
|
24
|
+
SUCCEEDED: 'succeeded',
|
|
25
|
+
FAILED: 'failed',
|
|
26
|
+
CANCELLED: 'cancelled',
|
|
27
|
+
ORPHANED: 'orphaned',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const TERMINAL = new Set([
|
|
31
|
+
TASK_STATUS.SUCCEEDED,
|
|
32
|
+
TASK_STATUS.FAILED,
|
|
33
|
+
TASK_STATUS.CANCELLED,
|
|
34
|
+
TASK_STATUS.ORPHANED,
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function isTerminalTaskStatus(status) {
|
|
38
|
+
return TERMINAL.has(status);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeSessionId(sessionId) {
|
|
42
|
+
const raw = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'default';
|
|
43
|
+
return raw.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeTaskId(taskId) {
|
|
47
|
+
const raw = typeof taskId === 'string' && taskId.trim() ? taskId.trim() : 'task_unknown';
|
|
48
|
+
return raw.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class TaskStore {
|
|
52
|
+
constructor({ yeaftDir }) {
|
|
53
|
+
if (!yeaftDir) throw new Error('TaskStore requires yeaftDir');
|
|
54
|
+
this.yeaftDir = yeaftDir;
|
|
55
|
+
this.root = join(yeaftDir, 'tasks', 'sessions');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sessionDir(sessionId) {
|
|
59
|
+
return join(this.root, safeSessionId(sessionId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
taskPath(sessionId, taskId) {
|
|
63
|
+
return join(this.sessionDir(sessionId), `${safeTaskId(taskId)}.json`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logPath(sessionId, taskId) {
|
|
67
|
+
return join(this.sessionDir(sessionId), `${safeTaskId(taskId)}.log`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
eventPath(sessionId) {
|
|
71
|
+
return join(this.sessionDir(sessionId), 'tasks.jsonl');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ensureSessionDir(sessionId) {
|
|
75
|
+
const dir = this.sessionDir(sessionId);
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
writeTask(task) {
|
|
81
|
+
if (!task?.id) throw new Error('TaskStore.writeTask requires task.id');
|
|
82
|
+
const sessionId = task.sessionId || 'default';
|
|
83
|
+
this.ensureSessionDir(sessionId);
|
|
84
|
+
writeFileSync(this.taskPath(sessionId, task.id), `${JSON.stringify(task, null, 2)}\n`, { encoding: 'utf8', mode: 0o644 });
|
|
85
|
+
return task;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
appendEvent(sessionId, event) {
|
|
89
|
+
this.ensureSessionDir(sessionId);
|
|
90
|
+
appendFileSync(this.eventPath(sessionId), `${JSON.stringify({ ...event, at: event.at || new Date().toISOString() })}\n`, { encoding: 'utf8', mode: 0o644 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
appendLog(sessionId, taskId, chunk) {
|
|
94
|
+
if (typeof chunk !== 'string' || chunk.length === 0) return;
|
|
95
|
+
this.ensureSessionDir(sessionId);
|
|
96
|
+
appendFileSync(this.logPath(sessionId, taskId), chunk, { encoding: 'utf8', mode: 0o644 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
readLogFile(path, { offset = 0, maxBytes = 64 * 1024, tail = false } = {}) {
|
|
100
|
+
if (!existsSync(path)) return { path, text: '', bytes: 0, offset: 0, nextOffset: 0 };
|
|
101
|
+
const st = statSync(path);
|
|
102
|
+
const bytes = st.size;
|
|
103
|
+
let start = Math.max(0, Number.isFinite(offset) ? Math.floor(offset) : 0);
|
|
104
|
+
const cap = Math.max(0, Math.min(Number.isFinite(maxBytes) ? Math.floor(maxBytes) : 64 * 1024, 1024 * 1024));
|
|
105
|
+
if (tail) start = Math.max(0, bytes - cap);
|
|
106
|
+
const end = Math.min(bytes, start + cap);
|
|
107
|
+
const length = Math.max(0, end - start);
|
|
108
|
+
if (length === 0) {
|
|
109
|
+
return { path, text: '', bytes, offset: start, nextOffset: end, truncated: end < bytes };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const fd = openSync(path, 'r');
|
|
113
|
+
try {
|
|
114
|
+
const buf = Buffer.allocUnsafe(length);
|
|
115
|
+
const readBytes = readSync(fd, buf, 0, length, start);
|
|
116
|
+
return {
|
|
117
|
+
path,
|
|
118
|
+
text: buf.subarray(0, readBytes).toString('utf8'),
|
|
119
|
+
bytes,
|
|
120
|
+
offset: start,
|
|
121
|
+
nextOffset: start + readBytes,
|
|
122
|
+
truncated: start + readBytes < bytes,
|
|
123
|
+
};
|
|
124
|
+
} finally {
|
|
125
|
+
closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
readLog(sessionId, taskId, opts = {}) {
|
|
130
|
+
return this.readLogFile(this.logPath(sessionId, taskId), opts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
readTask(sessionId, taskId) {
|
|
134
|
+
const path = this.taskPath(sessionId, taskId);
|
|
135
|
+
if (!existsSync(path)) return null;
|
|
136
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
loadActiveTasks() {
|
|
140
|
+
if (!existsSync(this.root)) return [];
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const sessionDirName of readdirSync(this.root, { withFileTypes: true })) {
|
|
143
|
+
if (!sessionDirName.isDirectory()) continue;
|
|
144
|
+
const sessionDir = join(this.root, sessionDirName.name);
|
|
145
|
+
for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
|
|
146
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
147
|
+
try {
|
|
148
|
+
const task = JSON.parse(readFileSync(join(sessionDir, entry.name), 'utf8'));
|
|
149
|
+
if (task && task.status === TASK_STATUS.RUNNING) out.push(task);
|
|
150
|
+
} catch {
|
|
151
|
+
// Ignore corrupt task metadata; one bad task must not block boot.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
statLog(sessionId, taskId) {
|
|
159
|
+
const path = this.logPath(sessionId, taskId);
|
|
160
|
+
try {
|
|
161
|
+
const st = statSync(path);
|
|
162
|
+
return { path, bytes: st.size, mtimeMs: st.mtimeMs };
|
|
163
|
+
} catch {
|
|
164
|
+
return { path, bytes: 0, mtimeMs: 0 };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -419,6 +419,15 @@ use it as the default workflow or call it repeatedly in a loop.`,
|
|
|
419
419
|
const deps = ctx?.parentEngineDeps;
|
|
420
420
|
if (deps && deps.adapter) {
|
|
421
421
|
try {
|
|
422
|
+
const task = ctx?.taskManager?.startTask?.({
|
|
423
|
+
sessionId: callerScope.sessionId || ctx?.sessionId || 'default',
|
|
424
|
+
ownerVpId: callerScope.parentVpId || ctx?.currentVpId || null,
|
|
425
|
+
kind: 'sub_agent',
|
|
426
|
+
title: spec.mission || spec.task || name,
|
|
427
|
+
runtime: { subAgentId: agentId, name, cwd: agent.cwd },
|
|
428
|
+
source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
|
|
429
|
+
});
|
|
430
|
+
if (task?.id) agent.taskId = task.id;
|
|
422
431
|
startSubAgent(agent, deps);
|
|
423
432
|
} catch (err) {
|
|
424
433
|
agent.status = STATUS.FAILED;
|
|
@@ -449,6 +458,7 @@ use it as the default workflow or call it repeatedly in a loop.`,
|
|
|
449
458
|
budget: spec.budget || null,
|
|
450
459
|
status: agent.status,
|
|
451
460
|
outputFile: agent.outputFile || null,
|
|
461
|
+
taskId: agent.taskId || null,
|
|
452
462
|
liveness,
|
|
453
463
|
stale: liveness.stale,
|
|
454
464
|
stalled: liveness.stalled,
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -12,7 +12,9 @@ import { defineTool } from './types.js';
|
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
13
|
import { existsSync } from 'fs';
|
|
14
14
|
import { resolve } from 'path';
|
|
15
|
-
import {
|
|
15
|
+
import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
|
|
16
|
+
|
|
17
|
+
export { buildShellInvocation };
|
|
16
18
|
|
|
17
19
|
/** Max output size in bytes before truncation (256 KB). */
|
|
18
20
|
const MAX_OUTPUT = 256 * 1024;
|
|
@@ -23,31 +25,6 @@ const DEFAULT_TIMEOUT_MS = 120_000;
|
|
|
23
25
|
/** Max timeout in ms (10 minutes). */
|
|
24
26
|
const MAX_TIMEOUT_MS = 600_000;
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} command
|
|
28
|
-
* @param {{ runtimePlatform?: object }} opts
|
|
29
|
-
*/
|
|
30
|
-
export function buildShellInvocation(command, opts = {}) {
|
|
31
|
-
const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
|
|
32
|
-
const shell = runtimePlatform.defaultShell
|
|
33
|
-
? {
|
|
34
|
-
command: runtimePlatform.defaultShell,
|
|
35
|
-
argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
|
|
36
|
-
family: runtimePlatform.shellFamily,
|
|
37
|
-
}
|
|
38
|
-
: resolveDefaultShell({ platform: runtimePlatform.platform });
|
|
39
|
-
|
|
40
|
-
const argsPrefix = Array.isArray(shell.argsPrefix)
|
|
41
|
-
? shell.argsPrefix
|
|
42
|
-
: resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
command: shell.command,
|
|
46
|
-
args: [...argsPrefix, command],
|
|
47
|
-
family: shell.family || runtimePlatform.shellFamily || 'posix',
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
28
|
/**
|
|
52
29
|
* Run a command in a child process.
|
|
53
30
|
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
|
|
@@ -169,7 +146,7 @@ Guidelines:
|
|
|
169
146
|
- Large outputs are truncated at 256KB
|
|
170
147
|
- Use absolute paths when possible
|
|
171
148
|
- Avoid interactive commands (no stdin support)
|
|
172
|
-
-
|
|
149
|
+
- Use background=true for long-running or persistent tasks that should survive across turns
|
|
173
150
|
- stderr is captured separately and included in the result`,
|
|
174
151
|
zh: `执行 Shell 命令并返回输出。
|
|
175
152
|
|
|
@@ -181,7 +158,7 @@ Guidelines:
|
|
|
181
158
|
- 大输出在 256KB 处截断
|
|
182
159
|
- 尽量使用绝对路径
|
|
183
160
|
- 避免交互式命令(不支持 stdin)
|
|
184
|
-
-
|
|
161
|
+
- 长时间或需要跨 turn 持续存在的任务使用 background=true
|
|
185
162
|
- stderr 单独捕获并包含在结果中`
|
|
186
163
|
},
|
|
187
164
|
parameters: {
|
|
@@ -208,6 +185,20 @@ Guidelines:
|
|
|
208
185
|
zh: `超时时间,单位毫秒(默认 ${DEFAULT_TIMEOUT_MS},最大 ${MAX_TIMEOUT_MS})`,
|
|
209
186
|
},
|
|
210
187
|
},
|
|
188
|
+
background: {
|
|
189
|
+
type: 'boolean',
|
|
190
|
+
description: {
|
|
191
|
+
en: 'Run as a persistent Session task and return immediately with a taskId and log path',
|
|
192
|
+
zh: '作为持久化 Session 后台任务运行,并立即返回 taskId 和日志路径',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
taskTitle: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
description: {
|
|
198
|
+
en: 'Human-readable title for the background task',
|
|
199
|
+
zh: '后台任务的人类可读标题',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
211
202
|
},
|
|
212
203
|
required: ['command'],
|
|
213
204
|
},
|
|
@@ -223,7 +214,7 @@ Guidelines:
|
|
|
223
214
|
cmd.includes('> /dev/') || cmd.includes('chmod 000');
|
|
224
215
|
},
|
|
225
216
|
async execute(input, ctx) {
|
|
226
|
-
const { command, cwd: inputCwd, timeout_ms } = input;
|
|
217
|
+
const { command, cwd: inputCwd, timeout_ms, background = false, taskTitle } = input;
|
|
227
218
|
if (!command) return JSON.stringify({ error: 'command is required' });
|
|
228
219
|
|
|
229
220
|
// Resolve working directory
|
|
@@ -239,6 +230,28 @@ Guidelines:
|
|
|
239
230
|
const timeout = Math.min(Math.max(timeout_ms || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
|
|
240
231
|
const runtimePlatform = ctx?.runtimePlatform || getRuntimePlatformInfo();
|
|
241
232
|
|
|
233
|
+
if (background) {
|
|
234
|
+
if (!ctx?.taskManager) {
|
|
235
|
+
return JSON.stringify({ error: 'background tasks are unavailable in this runtime' });
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const task = ctx.taskManager.startShellTask({
|
|
239
|
+
command,
|
|
240
|
+
cwd,
|
|
241
|
+
sessionId: ctx.sessionId || 'default',
|
|
242
|
+
ownerVpId: ctx.currentVpId || null,
|
|
243
|
+
title: taskTitle || command.slice(0, 120),
|
|
244
|
+
runtimePlatform,
|
|
245
|
+
source: {
|
|
246
|
+
threadId: ctx.threadId || 'main',
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return JSON.stringify({ error: err?.message || String(err) });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
242
255
|
try {
|
|
243
256
|
const result = await runCommand(command, {
|
|
244
257
|
cwd,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cancel-task.js — Cancel a running background task.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from './types.js';
|
|
6
|
+
|
|
7
|
+
export default defineTool({
|
|
8
|
+
name: 'CancelTask',
|
|
9
|
+
description: {
|
|
10
|
+
en: 'Cancel a running Session background task.',
|
|
11
|
+
zh: '取消正在运行的 Session 后台任务。',
|
|
12
|
+
},
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
|
|
17
|
+
sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
|
|
18
|
+
},
|
|
19
|
+
required: ['taskId'],
|
|
20
|
+
},
|
|
21
|
+
isConcurrencySafe: () => false,
|
|
22
|
+
isReadOnly: () => false,
|
|
23
|
+
async execute(input = {}, ctx = {}) {
|
|
24
|
+
if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
|
|
25
|
+
const taskId = input.taskId;
|
|
26
|
+
if (!taskId) return JSON.stringify({ error: 'taskId is required' });
|
|
27
|
+
const sessionId = input.sessionId || ctx.sessionId || 'default';
|
|
28
|
+
return JSON.stringify(ctx.taskManager.cancelTask(sessionId, taskId), null, 2);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -95,6 +95,14 @@ Do NOT end your turn silently right after CloseAgent.`,
|
|
|
95
95
|
const wasTerminal = isTerminalAgentStatus(agent.status);
|
|
96
96
|
if (!wasTerminal) {
|
|
97
97
|
agent.status = STATUS.CLOSED;
|
|
98
|
+
if (agent.taskId && ctx?.taskManager && agent.parentSessionId) {
|
|
99
|
+
try {
|
|
100
|
+
ctx.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
|
|
101
|
+
status: 'cancelled',
|
|
102
|
+
error: agent.error || null,
|
|
103
|
+
});
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
}
|
|
98
106
|
// The driver may not yet have observed the abort / status flip; push
|
|
99
107
|
// a notification so the queue stays consistent (idempotent inside
|
|
100
108
|
// the notifications module). The driver's own finalizeTerminal()
|
package/yeaft/tools/index.js
CHANGED
|
@@ -38,6 +38,9 @@ import globTool from './glob.js';
|
|
|
38
38
|
import grepTool from './grep.js';
|
|
39
39
|
import listDir from './list-dir.js';
|
|
40
40
|
import applyPatch from './apply-patch.js';
|
|
41
|
+
import listTasks from './list-tasks.js';
|
|
42
|
+
import readTaskLog from './read-task-log.js';
|
|
43
|
+
import cancelTask from './cancel-task.js';
|
|
41
44
|
|
|
42
45
|
// --- P1 Agent tools ---
|
|
43
46
|
import agentTool from './agent.js';
|
|
@@ -99,6 +102,9 @@ export const allTools = [
|
|
|
99
102
|
grepTool,
|
|
100
103
|
listDir,
|
|
101
104
|
applyPatch,
|
|
105
|
+
listTasks,
|
|
106
|
+
readTaskLog,
|
|
107
|
+
cancelTask,
|
|
102
108
|
|
|
103
109
|
// P1 Agent
|
|
104
110
|
agentTool,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list-tasks.js — List active Session background tasks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from './types.js';
|
|
6
|
+
|
|
7
|
+
export default defineTool({
|
|
8
|
+
name: 'ListTasks',
|
|
9
|
+
description: {
|
|
10
|
+
en: 'List currently running Session background tasks.',
|
|
11
|
+
zh: '列出当前正在运行的 Session 后台任务。',
|
|
12
|
+
},
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
sessionId: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
isConcurrencySafe: () => true,
|
|
23
|
+
isReadOnly: () => true,
|
|
24
|
+
async execute(input = {}, ctx = {}) {
|
|
25
|
+
if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
|
|
26
|
+
const sessionId = input.sessionId || ctx.sessionId || null;
|
|
27
|
+
return JSON.stringify({ tasks: ctx.taskManager.listActiveTasks(sessionId) }, null, 2);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-task-log.js — Read a background task log.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from './types.js';
|
|
6
|
+
|
|
7
|
+
export default defineTool({
|
|
8
|
+
name: 'ReadTaskLog',
|
|
9
|
+
description: {
|
|
10
|
+
en: 'Read a background task log by taskId. Supports tail reads and byte offsets.',
|
|
11
|
+
zh: '按 taskId 读取后台任务日志。支持 tail 和字节 offset。',
|
|
12
|
+
},
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
|
|
17
|
+
sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
|
|
18
|
+
offset: { type: 'number', description: { en: 'Byte offset to start reading from', zh: '开始读取的字节 offset' } },
|
|
19
|
+
maxBytes: { type: 'number', description: { en: 'Maximum bytes to read (max 1 MiB)', zh: '最多读取字节数(最大 1 MiB)' } },
|
|
20
|
+
tail: { type: 'boolean', description: { en: 'Read the last maxBytes bytes', zh: '读取最后 maxBytes 字节' } },
|
|
21
|
+
},
|
|
22
|
+
required: ['taskId'],
|
|
23
|
+
},
|
|
24
|
+
isConcurrencySafe: () => true,
|
|
25
|
+
isReadOnly: () => true,
|
|
26
|
+
async execute(input = {}, ctx = {}) {
|
|
27
|
+
if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
|
|
28
|
+
const taskId = input.taskId;
|
|
29
|
+
if (!taskId) return JSON.stringify({ error: 'taskId is required' });
|
|
30
|
+
const sessionId = input.sessionId || ctx.sessionId || 'default';
|
|
31
|
+
const result = ctx.taskManager.readTaskLog(sessionId, taskId, {
|
|
32
|
+
offset: input.offset,
|
|
33
|
+
maxBytes: input.maxBytes,
|
|
34
|
+
tail: input.tail !== false,
|
|
35
|
+
});
|
|
36
|
+
return JSON.stringify(result, null, 2);
|
|
37
|
+
},
|
|
38
|
+
});
|
package/yeaft/tools/types.js
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* @property {object} [skillManager] — Skill manager
|
|
22
22
|
* @property {object} [trace] — debug trace
|
|
23
23
|
* @property {object} [config] — engine config
|
|
24
|
+
* @property {import('../tasks/manager.js').TaskManager} [taskManager] — Session task manager
|
|
25
|
+
* @property {string} [sessionId] — current Session id
|
|
26
|
+
* @property {string} [threadId] — current Session thread id
|
|
24
27
|
* @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
|
|
25
28
|
* @property {string} [currentGroupId] — R6: group id of the caller's RoleInstance
|
|
26
29
|
* @property {(sessionId: string) => string[]|null} [getGroupRoster]
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -948,6 +948,7 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
|
|
|
948
948
|
// (`if (this.#toolStats && ...)`) is false and group VP tool calls
|
|
949
949
|
// are silently dropped.
|
|
950
950
|
toolStats: session.toolStats || null,
|
|
951
|
+
taskManager: session.taskManager || null,
|
|
951
952
|
// Per-VP fan-out: bind the engine to its (sessionId, vpId) so post-turn
|
|
952
953
|
// compact reads/writes a scoped summary instead of the legacy global
|
|
953
954
|
// compact.md (which every VP would otherwise share, producing
|
|
@@ -1943,6 +1944,15 @@ function buildVpPersona(vpId) {
|
|
|
1943
1944
|
export function installYeaftRuntimeBridge(s) {
|
|
1944
1945
|
if (!s) return;
|
|
1945
1946
|
|
|
1947
|
+
if (s.taskManager && typeof s.taskManager.setEventSink === 'function') {
|
|
1948
|
+
s.taskManager.setEventSink((event) => {
|
|
1949
|
+
try {
|
|
1950
|
+
const sessionId = event?.task?.sessionId || event?.sessionId || null;
|
|
1951
|
+
sendSessionEvent(event, { sessionId });
|
|
1952
|
+
} catch { /* never let task event delivery throw */ }
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1946
1956
|
// Forward dream pipeline progress events to the web debug panel.
|
|
1947
1957
|
//
|
|
1948
1958
|
// Group-id stamping is NO LONGER done here. It used to be: this sink
|
|
@@ -2896,6 +2906,7 @@ async function ensureSessionLoaded() {
|
|
|
2896
2906
|
mcpServers: session.status.mcpServers,
|
|
2897
2907
|
tools: session.status.tools,
|
|
2898
2908
|
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
2909
|
+
tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
|
|
2899
2910
|
});
|
|
2900
2911
|
sendSessionSnapshotBroadcast();
|
|
2901
2912
|
// vp-status: rebuild frontend status table from authoritative agent
|
|
@@ -4152,6 +4163,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4152
4163
|
mcpServers: session.status.mcpServers,
|
|
4153
4164
|
tools: session.status.tools,
|
|
4154
4165
|
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
4166
|
+
tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
|
|
4155
4167
|
});
|
|
4156
4168
|
sendSessionSnapshotBroadcast();
|
|
4157
4169
|
if (sessionId) {
|