@buihongduc132/pi-acp-agents 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +311 -211
- package/index.ts +309 -41
- package/package.json +1 -1
- package/src/acp-widget.ts +197 -0
- package/src/config/config.ts +9 -0
- package/src/config/types.ts +96 -0
- package/src/dag/dag-executor.ts +966 -0
- package/src/dag/dag-store.ts +408 -0
- package/src/dag/dag-validator.ts +202 -0
- package/src/dag/template-resolver.ts +174 -0
- package/src/management/governance-store.ts +10 -3
- package/src/management/legacy-migration.ts +79 -0
- package/src/management/mailbox-manager.ts +10 -3
- package/src/management/runtime-paths.ts +18 -7
- package/src/management/session-archive-store.ts +1 -1
- package/src/management/session-store-factory.ts +58 -0
- package/src/management/task-store.ts +10 -3
- package/src/management/worker-store.ts +10 -3
- package/src/settings/config.ts +3 -0
package/index.ts
CHANGED
|
@@ -3,13 +3,14 @@ import { join } from "node:path";
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import { type AcpWidgetState, createAcpWidget } from "./src/acp-widget.js";
|
|
6
|
+
import { type AcpWidgetState, type AcpWidgetDag, createAcpWidget, dagIndexEntryToWidgetDag } from "./src/acp-widget.js";
|
|
7
7
|
import { createAdapter } from "./src/adapter-factory.js";
|
|
8
8
|
import { loadConfig } from "./src/config/config.js";
|
|
9
|
-
import type { AcpArchivedSessionMetadata, AcpConfig, AcpPromptResult, AcpSessionHandle, AcpWorkerStatus } from "./src/config/types.js";
|
|
9
|
+
import type { AcpArchivedSessionMetadata, AcpConfig, AcpPromptResult, AcpSessionHandle, AcpWorkerStatus, DagIndexEntry } from "./src/config/types.js";
|
|
10
10
|
import { AgentCoordinator } from "./src/coordination/coordinator.js";
|
|
11
11
|
import { WorkerDispatcher, type WorkerDispatcherDeps } from "./src/coordination/worker-dispatcher.js";
|
|
12
12
|
import { AcpCircuitBreaker } from "./src/core/circuit-breaker.js";
|
|
13
|
+
import { AsyncExecutor } from "./src/core/async-executor.js";
|
|
13
14
|
import { HealthMonitor } from "./src/core/health-monitor.js";
|
|
14
15
|
import { getSessionAutoCloseReason } from "./src/core/session-lifecycle.js";
|
|
15
16
|
import { SessionManager } from "./src/core/session-manager.js";
|
|
@@ -22,7 +23,12 @@ import { AcpTaskStore, type AcpTaskStatus } from "./src/management/task-store.js
|
|
|
22
23
|
import { WorkerStore } from "./src/management/worker-store.js";
|
|
23
24
|
import { SessionArchiveStore } from "./src/management/session-archive-store.js";
|
|
24
25
|
import { SessionNameStore } from "./src/management/session-name-store.js";
|
|
26
|
+
import { SessionStoreFactory } from "./src/management/session-store-factory.js";
|
|
25
27
|
import { ensureRuntimeDir } from "./src/management/runtime-paths.js";
|
|
28
|
+
import { DagStore } from "./src/dag/dag-store.js";
|
|
29
|
+
import { DagValidator } from "./src/dag/dag-validator.js";
|
|
30
|
+
import { DagExecutor, type DagCancelSummary } from "./src/dag/dag-executor.js";
|
|
31
|
+
import { TemplateResolver } from "./src/dag/template-resolver.js";
|
|
26
32
|
import { loadSettings, isToolEnabled, type AcpToolSettings } from "./src/settings/config.js";
|
|
27
33
|
import { configureToolSettings } from "./src/settings/configure-tui.js";
|
|
28
34
|
|
|
@@ -81,13 +87,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
87
|
const logger = createFileLogger(logsDir);
|
|
82
88
|
const runtimePaths = ensureRuntimeDir(config.runtimeDir);
|
|
83
89
|
const eventLog = new AcpEventLog(runtimePaths.rootDir);
|
|
84
|
-
const taskStore = new AcpTaskStore(runtimePaths.rootDir);
|
|
85
|
-
const workerStore = new WorkerStore(runtimePaths.rootDir);
|
|
86
|
-
const mailboxManager = new MailboxManager(runtimePaths.rootDir);
|
|
87
|
-
const governanceStore = new GovernanceStore(runtimePaths.rootDir);
|
|
88
|
-
const sessionArchiveStore = new SessionArchiveStore(runtimePaths.rootDir);
|
|
89
90
|
const sessionNameStore = new SessionNameStore(runtimePaths.rootDir);
|
|
90
|
-
|
|
91
|
+
|
|
92
|
+
// Session-scoped stores — lazily created per host session ID
|
|
93
|
+
const storeFactory = new SessionStoreFactory(runtimePaths.rootDir);
|
|
94
|
+
let hostSessionId: string | undefined;
|
|
95
|
+
|
|
96
|
+
// Capture host session ID on session start (fires before any tool call)
|
|
97
|
+
pi.on("session_start", (_event, ctx) => {
|
|
98
|
+
hostSessionId = ctx.sessionManager.getSessionId();
|
|
99
|
+
// Apply governance policy to the session-scoped governance store
|
|
100
|
+
storeFactory.get(hostSessionId).governanceStore.setModelPolicy(config.modelPolicy ?? {});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Get session-scoped stores for the current host session. */
|
|
104
|
+
function getStores() {
|
|
105
|
+
const sid = hostSessionId ?? process.env.PI_SESSION_ID ?? "default";
|
|
106
|
+
return storeFactory.get(sid);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Convenience accessors for closures that need stores without ctx. */
|
|
110
|
+
const taskStore = () => getStores().taskStore;
|
|
111
|
+
const workerStore = () => getStores().workerStore;
|
|
112
|
+
const mailboxManager = () => getStores().mailboxManager;
|
|
113
|
+
const governanceStore = () => getStores().governanceStore;
|
|
114
|
+
|
|
115
|
+
// SessionArchiveStore is GLOBAL (catalogs all sessions) — not session-scoped
|
|
116
|
+
const sessionArchiveStore = new SessionArchiveStore(runtimePaths.rootDir);
|
|
91
117
|
|
|
92
118
|
const cb = new AcpCircuitBreaker(
|
|
93
119
|
config.circuitBreakerMaxFailures ?? 3,
|
|
@@ -95,6 +121,66 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
121
|
config.stallTimeoutMs ?? 300_000,
|
|
96
122
|
);
|
|
97
123
|
|
|
124
|
+
// DAG orchestration — file-backed store, validator, template resolver, and
|
|
125
|
+
// wave-based executor. Wired with existing infrastructure singletons below.
|
|
126
|
+
// The coordinator and async executor are created lazily per tool call where
|
|
127
|
+
// they already exist; the DagExecutor consults the coordinator on each step
|
|
128
|
+
// dispatch, so a placeholder is passed and the real coordinator is supplied
|
|
129
|
+
// at execute time inside acp_dag_submit.
|
|
130
|
+
//
|
|
131
|
+
// Task 3.3: `dagStore` is deliberately declared here (same closure scope as
|
|
132
|
+
// `workerStore` above and the `getWidgetState()` builder below) so it is
|
|
133
|
+
// directly reachable from getWidgetState() when it maps `dagStore.listAll()`
|
|
134
|
+
// into AcpWidgetState.dags. No hoisting is required — the instance lives at
|
|
135
|
+
// the extension-factory scope for the lifetime of the extension, matching
|
|
136
|
+
// the pattern already used by `workerStore`.
|
|
137
|
+
const dagStore = new DagStore({
|
|
138
|
+
dagDir: runtimePaths.dagDir,
|
|
139
|
+
dagIndexFile: runtimePaths.dagIndexFile,
|
|
140
|
+
});
|
|
141
|
+
const dagValidator = new DagValidator();
|
|
142
|
+
const dagTemplateResolver = new TemplateResolver({
|
|
143
|
+
truncateChars: config.dagOutputTruncateChars ?? 8000,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── Resume-on-startup hook (task 7.3, specs/dag-resume "Resume from last
|
|
147
|
+
// checkpoint after pi restart") ──
|
|
148
|
+
// On extension load, discover DAGs persisted in `running` state and resume
|
|
149
|
+
// each from its last checkpoint. The resume pass is fire-and-forget: the
|
|
150
|
+
// coordinator/executor construction and resumeAll() run inside an async
|
|
151
|
+
// IIFE so a failure (e.g. unreadable runtime dir) is caught and logged
|
|
152
|
+
// rather than thrown into the synchronous extension load path.
|
|
153
|
+
(async () => {
|
|
154
|
+
try {
|
|
155
|
+
const resumeCoordinator = new AgentCoordinator(config, process.cwd(), {
|
|
156
|
+
isHealthyFn: (name) => cb.isHealthy(name),
|
|
157
|
+
recordSuccessFn: (name) => cb.recordSuccess(name),
|
|
158
|
+
recordFailureFn: (name) => cb.recordFailure(name),
|
|
159
|
+
});
|
|
160
|
+
const resumeAsyncExecutor = new AsyncExecutor(resumeCoordinator, runtimePaths.rootDir);
|
|
161
|
+
const resumeExecutor = new DagExecutor({
|
|
162
|
+
store: dagStore,
|
|
163
|
+
resolver: dagTemplateResolver,
|
|
164
|
+
coordinator: resumeCoordinator,
|
|
165
|
+
asyncExecutor: resumeAsyncExecutor,
|
|
166
|
+
circuitBreaker: cb,
|
|
167
|
+
logger,
|
|
168
|
+
eventLog,
|
|
169
|
+
});
|
|
170
|
+
// Mark stale DAGs before resuming — a DAG with no step transitions
|
|
171
|
+
// for longer than dagStaleTimeoutMs is transitioned to `stale` and
|
|
172
|
+
// excluded from resume (specs/dag-stale-detection).
|
|
173
|
+
const staleTimeoutMs = config.dagStaleTimeoutMs ?? 3_600_000;
|
|
174
|
+
resumeExecutor.markStale(staleTimeoutMs);
|
|
175
|
+
|
|
176
|
+
await resumeExecutor.resumeAll();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
179
|
+
logger.error("DAG resume-on-startup failed", { error: message });
|
|
180
|
+
eventLog.append("dag_resume_failed", { error: message });
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
|
|
98
184
|
function archiveSession(handle: AcpSessionHandle): AcpArchivedSessionMetadata {
|
|
99
185
|
return sessionArchiveStore.upsert(handle);
|
|
100
186
|
}
|
|
@@ -185,7 +271,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
271
|
const heartbeatDeps = {
|
|
186
272
|
resolveWorkerName: (sid: string) => workerSessionMap.get(sid),
|
|
187
273
|
touch: (name: string, deltas?: { tokenDelta?: number; toolCallDelta?: number }) =>
|
|
188
|
-
workerStore.touch(name, deltas),
|
|
274
|
+
workerStore().touch(name, deltas),
|
|
189
275
|
logParseError: (entry: { workerName: string; sessionId: string; error: string }) =>
|
|
190
276
|
eventLog.append("heartbeat_parse_error", entry),
|
|
191
277
|
};
|
|
@@ -253,12 +339,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
253
339
|
|
|
254
340
|
if (workerAutoClaim) {
|
|
255
341
|
const dispatchDeps: WorkerDispatcherDeps = {
|
|
256
|
-
workerStore
|
|
257
|
-
taskStore
|
|
342
|
+
get workerStore() { return workerStore() as unknown as WorkerDispatcherDeps["workerStore"]; },
|
|
343
|
+
get taskStore() { return taskStore() as unknown as WorkerDispatcherDeps["taskStore"]; },
|
|
258
344
|
eventLog,
|
|
259
345
|
busySessions,
|
|
260
346
|
getSessionIdForWorker: (workerName: string) => {
|
|
261
|
-
const worker = workerStore.get(workerName);
|
|
347
|
+
const worker = workerStore().get(workerName);
|
|
262
348
|
if (!worker) return undefined;
|
|
263
349
|
// Find sessionId from the workerSessionMap by reversing the lookup
|
|
264
350
|
for (const [sid, name] of workerSessionMap.entries()) {
|
|
@@ -309,7 +395,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
395
|
configuredAliases: config.agent_aliases ? Object.keys(config.agent_aliases) : [],
|
|
310
396
|
defaultAgent: config.defaultAgent,
|
|
311
397
|
activity: { ...widgetActivity },
|
|
312
|
-
workers: workerStore.list().map((w) => {
|
|
398
|
+
workers: workerStore().list().map((w) => {
|
|
313
399
|
const now = Date.now();
|
|
314
400
|
const ageSec = Math.floor((now - new Date(w.lastActivityAt).getTime()) / 1000);
|
|
315
401
|
const derived = deriveWorkerStatus(w);
|
|
@@ -324,6 +410,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
324
410
|
currentTaskId: w.currentTaskId,
|
|
325
411
|
};
|
|
326
412
|
}),
|
|
413
|
+
// Task 3.2 / 3.4: populate `dags` from DagStore.listAll() — filter out
|
|
414
|
+
// `pending`, sort by `updatedAt` desc, cap at 5 entries. Field-name
|
|
415
|
+
// remapping DagIndexEntry → AcpWidgetDag is centralized and documented in
|
|
416
|
+
// `dagIndexEntryToWidgetDag()` (see src/acp-widget.ts).
|
|
417
|
+
dags: dagStore
|
|
418
|
+
.listAll()
|
|
419
|
+
.filter((e) => e.status !== "pending")
|
|
420
|
+
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
|
|
421
|
+
.slice(0, 5)
|
|
422
|
+
.map((e) => dagIndexEntryToWidgetDag(e)),
|
|
327
423
|
});
|
|
328
424
|
|
|
329
425
|
const widgetFactory = createAcpWidget({ getState: getWidgetState });
|
|
@@ -710,7 +806,7 @@ ${pr.text}`;
|
|
|
710
806
|
}),
|
|
711
807
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
712
808
|
config = loadConfig();
|
|
713
|
-
governanceStore.setModelPolicy(config.modelPolicy ?? {});
|
|
809
|
+
governanceStore().setModelPolicy(config.modelPolicy ?? {});
|
|
714
810
|
if (params.session_id || params.session_name) {
|
|
715
811
|
let target;
|
|
716
812
|
try {
|
|
@@ -844,7 +940,7 @@ ${pr.text}`;
|
|
|
844
940
|
// Bulk operation
|
|
845
941
|
if (params.task_id === "*") {
|
|
846
942
|
const filter = params.filter ?? "";
|
|
847
|
-
const updated = taskStore.updateWhere(filter, (t: any) => {
|
|
943
|
+
const updated = taskStore().updateWhere(filter, (t: any) => {
|
|
848
944
|
if (params.status) t.status = params.status;
|
|
849
945
|
if (params.assignee !== undefined) t.assignee = params.assignee || null;
|
|
850
946
|
if (params.result) t.result = params.result;
|
|
@@ -854,7 +950,7 @@ ${pr.text}`;
|
|
|
854
950
|
}
|
|
855
951
|
|
|
856
952
|
// Single task
|
|
857
|
-
const updated = taskStore.update(params.task_id, (t: any) => {
|
|
953
|
+
const updated = taskStore().update(params.task_id, (t: any) => {
|
|
858
954
|
if (params.status) t.status = params.status;
|
|
859
955
|
if (params.assignee !== undefined) t.assignee = params.assignee || null;
|
|
860
956
|
if (params.result) t.result = params.result;
|
|
@@ -892,7 +988,7 @@ ${pr.text}`;
|
|
|
892
988
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
893
989
|
if (params.action === "send") {
|
|
894
990
|
const kind: "dm" | "steer" | "broadcast" = params.to === "*" ? "broadcast" : ((params.kind as "dm" | "steer" | "broadcast" | undefined) ?? "dm");
|
|
895
|
-
const result = mailboxManager.send({
|
|
991
|
+
const result = mailboxManager().send({
|
|
896
992
|
from: params.from ?? "user",
|
|
897
993
|
to: params.to ?? "",
|
|
898
994
|
message: params.message ?? "",
|
|
@@ -903,11 +999,11 @@ ${pr.text}`;
|
|
|
903
999
|
|
|
904
1000
|
if (params.action === "list") {
|
|
905
1001
|
if (params.recipient) {
|
|
906
|
-
const messages = mailboxManager.listFor(params.recipient);
|
|
1002
|
+
const messages = mailboxManager().listFor(params.recipient);
|
|
907
1003
|
return { content: [textContent(`${messages.length} messages for ${params.recipient}.`)], details: { messages } };
|
|
908
1004
|
}
|
|
909
1005
|
// List all
|
|
910
|
-
const messages = mailboxManager.listAll?.() ?? [];
|
|
1006
|
+
const messages = mailboxManager().listAll?.() ?? [];
|
|
911
1007
|
return { content: [textContent(`${messages.length} total messages.`)], details: { messages } };
|
|
912
1008
|
}
|
|
913
1009
|
|
|
@@ -931,7 +1027,7 @@ ${pr.text}`;
|
|
|
931
1027
|
deps: Type.Optional(Type.Array(Type.String(), { description: "Task IDs this task depends on" })),
|
|
932
1028
|
}),
|
|
933
1029
|
async execute(_toolCallId, params) {
|
|
934
|
-
const task = taskStore.create({ subject: params.subject, description: params.description, assignee: params.assignee, deps: params.deps });
|
|
1030
|
+
const task = taskStore().create({ subject: params.subject, description: params.description, assignee: params.assignee, deps: params.deps });
|
|
935
1031
|
eventLog.append("task_create", { taskId: task.id, assignee: task.assignee });
|
|
936
1032
|
return { content: [textContent(formatJson(task))], details: task };
|
|
937
1033
|
},
|
|
@@ -1143,7 +1239,7 @@ ${pr.text}`;
|
|
|
1143
1239
|
}
|
|
1144
1240
|
|
|
1145
1241
|
// Check for duplicate name
|
|
1146
|
-
const existing = workerStore.get(name);
|
|
1242
|
+
const existing = workerStore().get(name);
|
|
1147
1243
|
if (existing) {
|
|
1148
1244
|
return {
|
|
1149
1245
|
content: [textContent(`Worker '${name}' already exists`)],
|
|
@@ -1166,7 +1262,7 @@ ${pr.text}`;
|
|
|
1166
1262
|
if (params.thinking) await adapter.setMode(params.thinking);
|
|
1167
1263
|
const handle = makeSessionHandle(sessionId, agentName, effectiveCwd, adapter);
|
|
1168
1264
|
// Register in WorkerStore
|
|
1169
|
-
const worker = workerStore.register({ name, sessionId, agentName });
|
|
1265
|
+
const worker = workerStore().register({ name, sessionId, agentName });
|
|
1170
1266
|
// Register session → worker mapping for heartbeat consumer
|
|
1171
1267
|
workerSessionMap.set(sessionId, name);
|
|
1172
1268
|
// Store adapter for dispatcher access
|
|
@@ -1222,7 +1318,7 @@ ${pr.text}`;
|
|
|
1222
1318
|
}),
|
|
1223
1319
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1224
1320
|
// Fetch all workers, then filter by derived status if requested
|
|
1225
|
-
const allWorkers = workerStore.list();
|
|
1321
|
+
const allWorkers = workerStore().list();
|
|
1226
1322
|
const rawFilter = params.filter as string | undefined;
|
|
1227
1323
|
const workers = rawFilter
|
|
1228
1324
|
? allWorkers.filter((w) => {
|
|
@@ -1294,7 +1390,7 @@ ${pr.text}`;
|
|
|
1294
1390
|
const message = requireString(params.message, "message");
|
|
1295
1391
|
|
|
1296
1392
|
// 4.5: Return error if worker not found
|
|
1297
|
-
const worker = workerStore.get(name);
|
|
1393
|
+
const worker = workerStore().get(name);
|
|
1298
1394
|
if (!worker) {
|
|
1299
1395
|
return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found", name } };
|
|
1300
1396
|
}
|
|
@@ -1309,7 +1405,7 @@ ${pr.text}`;
|
|
|
1309
1405
|
try {
|
|
1310
1406
|
await adapter.cancel();
|
|
1311
1407
|
// Cancel succeeded — queue steer for next prompt after worker returns to idle
|
|
1312
|
-
workerStore.updateMetadata(name, { pendingSteer: message });
|
|
1408
|
+
workerStore().updateMetadata(name, { pendingSteer: message });
|
|
1313
1409
|
eventLog.append("worker_steer_interrupt", { name, sessionId, message });
|
|
1314
1410
|
refreshWidget(ctx);
|
|
1315
1411
|
return {
|
|
@@ -1318,7 +1414,7 @@ ${pr.text}`;
|
|
|
1318
1414
|
};
|
|
1319
1415
|
} catch (err) {
|
|
1320
1416
|
// Interrupt failed — queue as next-prompt-prefix with warning
|
|
1321
|
-
workerStore.updateMetadata(name, { pendingSteer: message });
|
|
1417
|
+
workerStore().updateMetadata(name, { pendingSteer: message });
|
|
1322
1418
|
eventLog.append("worker_steer_queued", { name, sessionId, message, reason: "interrupt_failed" });
|
|
1323
1419
|
refreshWidget(ctx);
|
|
1324
1420
|
return {
|
|
@@ -1329,7 +1425,7 @@ ${pr.text}`;
|
|
|
1329
1425
|
}
|
|
1330
1426
|
|
|
1331
1427
|
// Idle or no adapter — queue as next-prompt-prefix (4.4)
|
|
1332
|
-
workerStore.updateMetadata(name, { pendingSteer: message });
|
|
1428
|
+
workerStore().updateMetadata(name, { pendingSteer: message });
|
|
1333
1429
|
eventLog.append("worker_steer_queued", { name, sessionId, message, reason: isBusy ? "no_adapter" : "idle" });
|
|
1334
1430
|
refreshWidget(ctx);
|
|
1335
1431
|
return { content: [textContent(`Steer message queued for worker '${name}': ${message}`)], details: { name, message, queued: true } };
|
|
@@ -1353,9 +1449,9 @@ ${pr.text}`;
|
|
|
1353
1449
|
// Determine which workers to shut down
|
|
1354
1450
|
let targets: string[];
|
|
1355
1451
|
if (params.all) {
|
|
1356
|
-
targets = workerStore.list().filter((w) => w.status !== "offline").map((w) => w.name);
|
|
1452
|
+
targets = workerStore().list().filter((w) => w.status !== "offline").map((w) => w.name);
|
|
1357
1453
|
} else if (params.name) {
|
|
1358
|
-
const worker = workerStore.get(params.name);
|
|
1454
|
+
const worker = workerStore().get(params.name);
|
|
1359
1455
|
if (!worker) {
|
|
1360
1456
|
return { content: [textContent(`Worker '${params.name}' not found`)], details: { error: "worker_not_found" } };
|
|
1361
1457
|
}
|
|
@@ -1367,7 +1463,7 @@ ${pr.text}`;
|
|
|
1367
1463
|
const results: Array<{ name: string; ok: boolean; error?: string }> = [];
|
|
1368
1464
|
|
|
1369
1465
|
for (const name of targets) {
|
|
1370
|
-
const worker = workerStore.get(name);
|
|
1466
|
+
const worker = workerStore().get(name);
|
|
1371
1467
|
if (!worker) continue;
|
|
1372
1468
|
|
|
1373
1469
|
try {
|
|
@@ -1376,11 +1472,11 @@ ${pr.text}`;
|
|
|
1376
1472
|
const startTime = Date.now();
|
|
1377
1473
|
while (worker.currentTaskId && (Date.now() - startTime) < shutdownTimeoutMs) {
|
|
1378
1474
|
await new Promise((r) => setTimeout(r, 500));
|
|
1379
|
-
const fresh = workerStore.get(name);
|
|
1475
|
+
const fresh = workerStore().get(name);
|
|
1380
1476
|
if (fresh && !fresh.currentTaskId) break;
|
|
1381
1477
|
}
|
|
1382
1478
|
// Check if still busy after timeout
|
|
1383
|
-
const afterWait = workerStore.get(name);
|
|
1479
|
+
const afterWait = workerStore().get(name);
|
|
1384
1480
|
if (afterWait?.currentTaskId) {
|
|
1385
1481
|
results.push({ name, ok: false, error: `Shutdown timed out; worker '${name}' still busy. Use acp_worker_kill to force.` });
|
|
1386
1482
|
continue;
|
|
@@ -1398,7 +1494,7 @@ ${pr.text}`;
|
|
|
1398
1494
|
busySessions.delete(sessionId);
|
|
1399
1495
|
|
|
1400
1496
|
// Mark worker offline
|
|
1401
|
-
workerStore.updateStatus(name, "offline");
|
|
1497
|
+
workerStore().updateStatus(name, "offline");
|
|
1402
1498
|
|
|
1403
1499
|
// 6.2: Emit worker_shutdown event
|
|
1404
1500
|
eventLog.append("worker_shutdown", { name, sessionId });
|
|
@@ -1430,7 +1526,7 @@ ${pr.text}`;
|
|
|
1430
1526
|
}),
|
|
1431
1527
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1432
1528
|
const name = requireString(params.name, "name");
|
|
1433
|
-
const worker = workerStore.get(name);
|
|
1529
|
+
const worker = workerStore().get(name);
|
|
1434
1530
|
if (!worker) {
|
|
1435
1531
|
return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found" } };
|
|
1436
1532
|
}
|
|
@@ -1448,13 +1544,13 @@ ${pr.text}`;
|
|
|
1448
1544
|
// Unassign active tasks (set to pending)
|
|
1449
1545
|
if (worker.currentTaskId) {
|
|
1450
1546
|
try {
|
|
1451
|
-
taskStore.update(worker.currentTaskId, (t) => { t.status = "pending"; });
|
|
1547
|
+
taskStore().update(worker.currentTaskId, (t) => { t.status = "pending"; });
|
|
1452
1548
|
} catch { /* ignore */ }
|
|
1453
|
-
workerStore.unassignTask(name);
|
|
1549
|
+
workerStore().unassignTask(name);
|
|
1454
1550
|
}
|
|
1455
1551
|
|
|
1456
1552
|
// Mark worker offline
|
|
1457
|
-
workerStore.updateStatus(name, "offline");
|
|
1553
|
+
workerStore().updateStatus(name, "offline");
|
|
1458
1554
|
|
|
1459
1555
|
// 6.2: Emit worker_shutdown event
|
|
1460
1556
|
eventLog.append("worker_shutdown", { name, sessionId, forced: true });
|
|
@@ -1473,7 +1569,7 @@ ${pr.text}`;
|
|
|
1473
1569
|
promptSnippet: "acp_worker_prune — prune stale workers",
|
|
1474
1570
|
parameters: Type.Object({}),
|
|
1475
1571
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1476
|
-
const workers = workerStore.list();
|
|
1572
|
+
const workers = workerStore().list();
|
|
1477
1573
|
const pruned: string[] = [];
|
|
1478
1574
|
|
|
1479
1575
|
for (const w of workers) {
|
|
@@ -1483,12 +1579,12 @@ ${pr.text}`;
|
|
|
1483
1579
|
// Unassign active tasks
|
|
1484
1580
|
if (w.currentTaskId) {
|
|
1485
1581
|
try {
|
|
1486
|
-
taskStore.update(w.currentTaskId, (t) => { t.status = "pending"; });
|
|
1582
|
+
taskStore().update(w.currentTaskId, (t) => { t.status = "pending"; });
|
|
1487
1583
|
} catch { /* ignore */ }
|
|
1488
|
-
workerStore.unassignTask(w.name);
|
|
1584
|
+
workerStore().unassignTask(w.name);
|
|
1489
1585
|
}
|
|
1490
1586
|
// Mark offline
|
|
1491
|
-
workerStore.updateStatus(w.name, "offline");
|
|
1587
|
+
workerStore().updateStatus(w.name, "offline");
|
|
1492
1588
|
pruned.push(w.name);
|
|
1493
1589
|
}
|
|
1494
1590
|
}
|
|
@@ -1501,6 +1597,178 @@ ${pr.text}`;
|
|
|
1501
1597
|
},
|
|
1502
1598
|
});
|
|
1503
1599
|
|
|
1600
|
+
// ── DAG tools ────────────────────────────────────────────────────────
|
|
1601
|
+
|
|
1602
|
+
if (isToolEnabled(toolSettings, "acp_dag_submit")) pi.registerTool({
|
|
1603
|
+
name: "acp_dag_submit",
|
|
1604
|
+
label: "ACP DAG Submit",
|
|
1605
|
+
description: "Submit a complete DAG (directed acyclic graph) of ACP agent tasks in a single call. Validates statically (cycles, dangling refs, duplicate IDs, agent availability, reserved IDs), creates the DAG, starts wave-based background execution, and returns the dagId immediately.",
|
|
1606
|
+
promptSnippet: "acp_dag_submit — submit a DAG of ACP agent tasks and start background execution",
|
|
1607
|
+
parameters: Type.Object({
|
|
1608
|
+
tasks: Type.Array(Type.Object({
|
|
1609
|
+
id: Type.String({ description: "Unique step identifier" }),
|
|
1610
|
+
agent: Type.String({ description: "Agent name (must exist in agent_servers config)" }),
|
|
1611
|
+
prompt: Type.String({ description: "Prompt text. May contain {<step-id>.output}, {<step-id>.status}, {dag.args.<key>} template variables." }),
|
|
1612
|
+
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Step IDs this step depends on (default: [])" })),
|
|
1613
|
+
gate: Type.Optional(Type.Union([Type.Literal("needs"), Type.Literal("after")], { description: "Gate type for ALL dependencies. needs = success-gate (default), after = completion-gate" })),
|
|
1614
|
+
}), { description: "DAG task definitions" }),
|
|
1615
|
+
args: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Workflow-level arguments for {dag.args.*} template variables" })),
|
|
1616
|
+
options: Type.Optional(Type.Object({
|
|
1617
|
+
failFast: Type.Optional(Type.Boolean({ description: "On failure, skip transitive dependents. Default: true" })),
|
|
1618
|
+
maxRetries: Type.Optional(Type.Number({ description: "Retry attempts per step on failure. Default: 0" })),
|
|
1619
|
+
})),
|
|
1620
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for all DAG steps" })),
|
|
1621
|
+
}),
|
|
1622
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1623
|
+
const tasks = params.tasks;
|
|
1624
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
1625
|
+
return { content: [textContent("acp_dag_submit requires a non-empty \"tasks\" array.")], details: { error: "no tasks" } };
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// 1. Static validation against the configured agent set.
|
|
1629
|
+
const agentNames = new Set(Object.keys(config.agent_servers));
|
|
1630
|
+
const validation = dagValidator.validate(tasks, agentNames);
|
|
1631
|
+
if (!validation.valid) {
|
|
1632
|
+
const message = `DAG validation failed: ${validation.errors.join("; ")}`;
|
|
1633
|
+
eventLog.append("dag_submit_rejected", { errors: validation.errors });
|
|
1634
|
+
return { content: [textContent(message)], details: { error: "validation_failed", violations: validation.errors } };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// 2. Create the DAG record via the file-backed store.
|
|
1638
|
+
const record = dagStore.create({
|
|
1639
|
+
tasks,
|
|
1640
|
+
args: params.args,
|
|
1641
|
+
options: params.options ?? {},
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
// 3. Build a per-call coordinator + executor with the current cwd and
|
|
1645
|
+
// circuit breaker, then kick off execution in the background
|
|
1646
|
+
// (fire-and-forget). The dagId is returned immediately.
|
|
1647
|
+
const coordinator = new AgentCoordinator(config, params.cwd ?? ctx.cwd, {
|
|
1648
|
+
isHealthyFn: (name) => cb.isHealthy(name),
|
|
1649
|
+
recordSuccessFn: (name) => cb.recordSuccess(name),
|
|
1650
|
+
recordFailureFn: (name) => cb.recordFailure(name),
|
|
1651
|
+
});
|
|
1652
|
+
// Existing AsyncExecutor singleton wired into the DagExecutor (task
|
|
1653
|
+
// 7.1). The wave loop is driven directly by the executor (design.md
|
|
1654
|
+
// D2 / task 5.3), but the AsyncExecutor is retained on the instance
|
|
1655
|
+
// for integration with the rest of the background-dispatch infra.
|
|
1656
|
+
const asyncExecutor = new AsyncExecutor(coordinator, runtimePaths.rootDir);
|
|
1657
|
+
const dagExecutor = new DagExecutor({
|
|
1658
|
+
store: dagStore,
|
|
1659
|
+
resolver: dagTemplateResolver,
|
|
1660
|
+
coordinator,
|
|
1661
|
+
asyncExecutor,
|
|
1662
|
+
circuitBreaker: cb,
|
|
1663
|
+
logger,
|
|
1664
|
+
eventLog,
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
// Fire-and-forget: errors are captured per step into the DAG state file.
|
|
1668
|
+
dagExecutor.execute(record.dagId).catch((err) => {
|
|
1669
|
+
logger.error(`acp_dag_submit background execution failed for dagId=${record.dagId}`, { error: err instanceof Error ? err.message : String(err) });
|
|
1670
|
+
eventLog.append("dag_execute_failed", { dagId: record.dagId, error: err instanceof Error ? err.message : String(err) });
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
eventLog.append("dag_submitted", { dagId: record.dagId, stepCount: tasks.length });
|
|
1674
|
+
return {
|
|
1675
|
+
content: [textContent(`Submitted DAG "${record.dagId}" with ${tasks.length} step(s). Execution started in the background.`)],
|
|
1676
|
+
details: { dagId: record.dagId, stepCount: tasks.length },
|
|
1677
|
+
};
|
|
1678
|
+
},
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
if (isToolEnabled(toolSettings, "acp_dag_status")) pi.registerTool({
|
|
1682
|
+
name: "acp_dag_status",
|
|
1683
|
+
label: "ACP DAG Status",
|
|
1684
|
+
description: "Query the execution state of a DAG. With a dagId, returns the full DAG state (status, all steps with their statuses, outputs, errors, dependencies, wave progress). Without a dagId, lists all DAGs with summary status.",
|
|
1685
|
+
promptSnippet: "acp_dag_status — get full DAG state or list all DAGs",
|
|
1686
|
+
parameters: Type.Object({
|
|
1687
|
+
dagId: Type.Optional(Type.String({ description: "DAG ID to inspect. Omit to list all DAGs." })),
|
|
1688
|
+
}),
|
|
1689
|
+
async execute(_toolCallId, params): Promise<AgentToolResult<{ dagId?: string; status?: string; currentWave?: number; totalWaves?: number; dags?: DagIndexEntry[]; count?: number; error?: string }>> {
|
|
1690
|
+
const dagId = params.dagId?.trim();
|
|
1691
|
+
|
|
1692
|
+
// Listing mode: no dagId provided → return all DAGs from the index.
|
|
1693
|
+
if (!dagId) {
|
|
1694
|
+
const dags = dagStore.listAll();
|
|
1695
|
+
return {
|
|
1696
|
+
content: [textContent(formatJson({ dags }))],
|
|
1697
|
+
details: { dags, count: dags.length },
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Detail mode: dagId provided → return the full DAG record.
|
|
1702
|
+
const record = dagStore.get(dagId);
|
|
1703
|
+
if (!record) {
|
|
1704
|
+
return {
|
|
1705
|
+
content: [textContent(`DAG "${dagId}" not found`)],
|
|
1706
|
+
details: { error: "not_found", dagId },
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return {
|
|
1711
|
+
content: [textContent(formatJson(record))],
|
|
1712
|
+
details: { dagId, status: record.status, currentWave: record.currentWave, totalWaves: record.totalWaves },
|
|
1713
|
+
};
|
|
1714
|
+
},
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
if (isToolEnabled(toolSettings, "acp_dag_cancel")) pi.registerTool({
|
|
1718
|
+
name: "acp_dag_cancel",
|
|
1719
|
+
label: "ACP DAG Cancel",
|
|
1720
|
+
description: "Cancel a running DAG. Aborts in-flight agent sessions, marks all pending/running steps as cancelled, transitions the DAG to cancelled, and returns a summary of the cancellation.",
|
|
1721
|
+
promptSnippet: "acp_dag_cancel — cancel a running DAG and return a cancellation summary",
|
|
1722
|
+
parameters: Type.Object({
|
|
1723
|
+
dagId: Type.String({ description: "DAG ID to cancel" }),
|
|
1724
|
+
}),
|
|
1725
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<{ dagId: string; completed?: number; aborted?: number; cancelled?: number; error?: string }>> {
|
|
1726
|
+
const dagId = (params.dagId ?? "").toString().trim();
|
|
1727
|
+
if (!dagId) {
|
|
1728
|
+
return {
|
|
1729
|
+
content: [textContent("acp_dag_cancel requires a non-empty \"dagId\".")],
|
|
1730
|
+
details: { dagId, error: "no_dagId" },
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Build a per-call coordinator + executor (same wiring as
|
|
1735
|
+
// acp_dag_submit). DagExecutor.cancel() reads the persisted step states
|
|
1736
|
+
// from the DagStore to tally the summary and abort in-flight sessions.
|
|
1737
|
+
const coordinator = new AgentCoordinator(config, ctx.cwd, {
|
|
1738
|
+
isHealthyFn: (name) => cb.isHealthy(name),
|
|
1739
|
+
recordSuccessFn: (name) => cb.recordSuccess(name),
|
|
1740
|
+
recordFailureFn: (name) => cb.recordFailure(name),
|
|
1741
|
+
});
|
|
1742
|
+
const asyncExecutor = new AsyncExecutor(coordinator, runtimePaths.rootDir);
|
|
1743
|
+
const dagExecutor = new DagExecutor({
|
|
1744
|
+
store: dagStore,
|
|
1745
|
+
resolver: dagTemplateResolver,
|
|
1746
|
+
coordinator,
|
|
1747
|
+
asyncExecutor,
|
|
1748
|
+
circuitBreaker: cb,
|
|
1749
|
+
logger,
|
|
1750
|
+
eventLog,
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
try {
|
|
1754
|
+
const summary: DagCancelSummary = await dagExecutor.cancel(dagId);
|
|
1755
|
+
eventLog.append("dag_cancelled", { dagId, ...summary });
|
|
1756
|
+
return {
|
|
1757
|
+
content: [textContent(formatJson({ dagId, ...summary }))],
|
|
1758
|
+
details: { dagId, ...summary },
|
|
1759
|
+
};
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1762
|
+
logger.error(`acp_dag_cancel failed for dagId=${dagId}`, { error: message });
|
|
1763
|
+
eventLog.append("dag_cancel_failed", { dagId, error: message });
|
|
1764
|
+
return {
|
|
1765
|
+
content: [textContent(message)],
|
|
1766
|
+
details: { dagId, error: "cancel_failed" },
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
},
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1504
1772
|
pi.registerCommand("acp-doctor", {
|
|
1505
1773
|
description: "Compatibility alias for /acp runtime doctor",
|
|
1506
1774
|
async handler(_args, ctx) {
|
package/package.json
CHANGED