pi-crew 0.1.40 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +9 -2
- package/docs/usage.md +11 -0
- package/package.json +1 -1
- package/schema.json +3 -1
- package/src/config/config.ts +6 -0
- package/src/extension/result-watcher.ts +33 -8
- package/src/extension/team-tool/api.ts +11 -1
- package/src/extension/team-tool/doctor.ts +31 -0
- package/src/extension/team-tool/status.ts +23 -3
- package/src/runtime/agent-control.ts +4 -5
- package/src/runtime/attention-events.ts +23 -0
- package/src/runtime/completion-guard.ts +99 -0
- package/src/runtime/crew-agent-runtime.ts +2 -2
- package/src/runtime/group-join.ts +22 -4
- package/src/runtime/task-runner.ts +25 -2
- package/src/runtime/team-runner.ts +57 -3
- package/src/schema/config-schema.ts +2 -0
- package/src/schema/team-tool-schema.ts +12 -3
- package/src/state/mailbox.ts +11 -1
- package/src/state/types.ts +13 -0
- package/src/ui/dashboard-panes/progress-pane.ts +3 -0
- package/src/ui/run-snapshot-cache.ts +29 -1
- package/src/ui/snapshot-types.ts +8 -0
- package/src/utils/fs-watch.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.41
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added strict-provider-friendly team tool schema shapes and config schema coverage for result delivery controls.
|
|
10
|
+
- Added resilient result watcher fallback polling for resource-limit watch failures and partial JSON retry handling.
|
|
11
|
+
- Added `runtime.completionMutationGuard` (`off`/`warn`/`fail`) with structured `task.attention` events when implementation-style workers complete without observed mutations.
|
|
12
|
+
- Added group-join mailbox delivery metadata, request-id dedupe, ack observability, timeout events, and dashboard/status visibility.
|
|
13
|
+
- Expanded `team doctor` and `team status` with schema, async/result delivery, worktree/readiness, attention, transcript, and group-join diagnostics.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Recovered adaptive implementation planner output when compaction truncates the end marker but complete phase objects are still present.
|
|
18
|
+
|
|
5
19
|
## 0.1.40
|
|
6
20
|
|
|
7
21
|
### Added
|
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ Current highlights:
|
|
|
60
60
|
- reliability hardening: heartbeat gradient watcher, opt-in retry executor with attempt trace, crash-recovery detection, deadletter queue
|
|
61
61
|
- background `Agent`/`crew_agent` completion wake-up so parent sessions can automatically join completed subagent results
|
|
62
62
|
- optional `runtime.requirePlanApproval` gate for planner-first approval before mutating adaptive implementation workers run
|
|
63
|
+
- optional `runtime.completionMutationGuard` to warn or fail implementation-style workers that complete without observed mutation tool calls
|
|
64
|
+
- grouped result delivery is correlated through mailbox metadata, deduped by request id, and acknowledged via existing `ack-message`
|
|
63
65
|
- shared redaction for common secrets before durable event/log/mailbox/artifact/metric/diagnostic persistence
|
|
64
66
|
- package polish: `schema.json`, TypeScript semantic check, strip-types import smoke, cross-platform CI workflow, dry-run package verification
|
|
65
67
|
|
|
@@ -176,7 +178,10 @@ Supported config:
|
|
|
176
178
|
},
|
|
177
179
|
"runtime": {
|
|
178
180
|
"mode": "auto",
|
|
179
|
-
"
|
|
181
|
+
"groupJoin": "smart",
|
|
182
|
+
"groupJoinAckTimeoutMs": 300000,
|
|
183
|
+
"requirePlanApproval": false,
|
|
184
|
+
"completionMutationGuard": "warn"
|
|
180
185
|
},
|
|
181
186
|
"limits": {
|
|
182
187
|
"maxConcurrentWorkers": 3,
|
|
@@ -237,6 +242,8 @@ Safety notes:
|
|
|
237
242
|
- Background `Agent`/`crew_agent` runs notify the parent session when they reach a terminal state; the parent can then call `get_subagent_result`/`crew_agent_result` and continue the original task.
|
|
238
243
|
- `tools.terminateOnForeground` is an opt-in power-user setting. When true, foreground `Agent`/`crew_agent` calls return with `terminate: true` after the child result is available, saving one follow-up LLM turn. Default is false so the assistant can still summarize raw worker output.
|
|
239
244
|
- Runtime state paths are treated as untrusted data: run ids, import bundles, artifact/transcript paths, mailbox files, and agent control/log files are validated with containment checks before reads or writes.
|
|
245
|
+
- `runtime.completionMutationGuard` defaults to `warn`; set `off` to disable or `fail` to fail implementation-style tasks that report success without observed mutation tool calls.
|
|
246
|
+
- Group-join result messages use normal mailbox delivery and normal `ack-message`; missing acknowledgements never block run completion, and duplicate delivery attempts reuse the same request id/message instead of appending spam.
|
|
240
247
|
- Common secret patterns (`token=`, `apiKey=`, `Authorization: Bearer ...`, private keys, etc.) are redacted before durable logs/events/mailbox/artifacts/metrics/diagnostics are written.
|
|
241
248
|
- `observability.enabled` defaults to true for in-memory metrics and heartbeat watching. Metric JSONL snapshots are gated by `telemetry.enabled`; set `telemetry.enabled=false` to opt out of local telemetry files.
|
|
242
249
|
- `reliability.autoRetry` and `reliability.autoRecover` default to false. Enabling retry may execute an idempotent task more than once; each attempt is recorded in `task.attempts`, and exhausted retries append a deadletter entry.
|
|
@@ -472,7 +479,7 @@ Manual slash commands are ops/debug controls. Autonomous tool use via policy/rec
|
|
|
472
479
|
/team-api team_... send-message taskId=task_... direction=inbox to=worker body="task scoped message"
|
|
473
480
|
/team-api team_... read-mailbox direction=outbox
|
|
474
481
|
/team-api team_... read-mailbox taskId=task_... direction=inbox
|
|
475
|
-
/team-api team_... ack-message messageId=msg_...
|
|
482
|
+
/team-api team_... ack-message messageId=msg_... # also acknowledges group-join result messages
|
|
476
483
|
/team-api team_... read-delivery
|
|
477
484
|
/team-api team_... validate-mailbox repair=true
|
|
478
485
|
/team-api team_... approve-plan
|
package/docs/usage.md
CHANGED
|
@@ -29,6 +29,13 @@ Supported fields:
|
|
|
29
29
|
"preferAsyncForLongTasks": false,
|
|
30
30
|
"allowWorktreeSuggestion": true
|
|
31
31
|
},
|
|
32
|
+
"runtime": {
|
|
33
|
+
"mode": "auto",
|
|
34
|
+
"groupJoin": "smart",
|
|
35
|
+
"groupJoinAckTimeoutMs": 300000,
|
|
36
|
+
"completionMutationGuard": "warn",
|
|
37
|
+
"requirePlanApproval": false
|
|
38
|
+
},
|
|
32
39
|
"ui": {
|
|
33
40
|
"widgetPlacement": "aboveEditor",
|
|
34
41
|
"widgetMaxLines": 8,
|
|
@@ -113,6 +120,10 @@ Background `Agent`/`crew_agent` subagents wake the parent Pi session when they c
|
|
|
113
120
|
|
|
114
121
|
State paths are validated before read/write operations. Run ids, imported bundles, artifact and transcript references, mailbox files, and agent control/log files must stay inside their expected `.crew` roots and symlink escapes are rejected. Read-only mailbox APIs return default state without creating mailbox files when no messages exist.
|
|
115
122
|
|
|
123
|
+
Group-join result delivery uses the normal outbox mailbox and normal `/team-api ... ack-message`. `runtime.groupJoinAckTimeoutMs` only emits observability (`agent.group_join.ack_timeout`) and does not block run completion.
|
|
124
|
+
|
|
125
|
+
`runtime.completionMutationGuard` defaults to `warn`. Use `off` to disable or `fail` to fail implementation-style workers that complete without observed mutation tool calls.
|
|
126
|
+
|
|
116
127
|
## Worktree mode
|
|
117
128
|
|
|
118
129
|
```json
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -69,7 +69,9 @@
|
|
|
69
69
|
"inheritContext": { "type": "boolean" },
|
|
70
70
|
"promptMode": { "type": "string", "enum": ["replace", "append"] },
|
|
71
71
|
"groupJoin": { "type": "string", "enum": ["off", "group", "smart"] },
|
|
72
|
-
"
|
|
72
|
+
"groupJoinAckTimeoutMs": { "type": "integer", "minimum": 1 },
|
|
73
|
+
"requirePlanApproval": { "type": "boolean" },
|
|
74
|
+
"completionMutationGuard": { "type": "string", "enum": ["off", "warn", "fail"] }
|
|
73
75
|
}
|
|
74
76
|
},
|
|
75
77
|
"control": {
|
package/src/config/config.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface CrewLimitsConfig {
|
|
|
30
30
|
|
|
31
31
|
export type CrewRuntimeMode = "auto" | "scaffold" | "child-process" | "live-session";
|
|
32
32
|
|
|
33
|
+
export type CompletionMutationGuardMode = "off" | "warn" | "fail";
|
|
34
|
+
|
|
33
35
|
export interface CrewRuntimeConfig {
|
|
34
36
|
mode?: CrewRuntimeMode;
|
|
35
37
|
preferLiveSession?: boolean;
|
|
@@ -39,7 +41,9 @@ export interface CrewRuntimeConfig {
|
|
|
39
41
|
inheritContext?: boolean;
|
|
40
42
|
promptMode?: "replace" | "append";
|
|
41
43
|
groupJoin?: "off" | "group" | "smart";
|
|
44
|
+
groupJoinAckTimeoutMs?: number;
|
|
42
45
|
requirePlanApproval?: boolean;
|
|
46
|
+
completionMutationGuard?: CompletionMutationGuardMode;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export interface CrewControlConfig {
|
|
@@ -493,7 +497,9 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
|
|
|
493
497
|
inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
|
|
494
498
|
promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
|
|
495
499
|
groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
|
|
500
|
+
groupJoinAckTimeoutMs: parsePositiveInteger(obj.groupJoinAckTimeoutMs, 86_400_000),
|
|
496
501
|
requirePlanApproval: parseWithSchema(Type.Boolean(), obj.requirePlanApproval),
|
|
502
|
+
completionMutationGuard: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")]), obj.completionMutationGuard),
|
|
497
503
|
};
|
|
498
504
|
return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
|
|
499
505
|
}
|
|
@@ -26,6 +26,12 @@ export interface ResultWatcherOptions extends ResultWatcherDependencies {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const RESULT_WATCHER_RESTART_MS = 3000;
|
|
29
|
+
const RESULT_WATCHER_POLL_MS = 1000;
|
|
30
|
+
|
|
31
|
+
function shouldFallBackToPolling(error: unknown): boolean {
|
|
32
|
+
const code = error && typeof error === "object" ? (error as { code?: unknown }).code : undefined;
|
|
33
|
+
return code === "EMFILE" || code === "ENOSPC" || code === "EPERM";
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
function readJson(filePath: string): unknown | undefined {
|
|
31
37
|
try {
|
|
@@ -45,16 +51,19 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
45
51
|
const seen = getGlobalSeenMap("pi-crew.result-watcher");
|
|
46
52
|
let watcher: fs.FSWatcher | null | undefined;
|
|
47
53
|
let restartTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
48
55
|
const coalescer = createFileCoalescer((file) => {
|
|
49
56
|
if (!isCurrent()) return;
|
|
50
57
|
const filePath = path.join(resultsDir, file);
|
|
51
58
|
if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
|
|
52
59
|
const payload = readJson(filePath);
|
|
53
|
-
if (payload
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
if (payload === undefined) {
|
|
61
|
+
coalescer.schedule(file, RESULT_WATCHER_POLL_MS);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const key = buildCompletionKey(payload as Record<string, unknown>, `file:${file}`);
|
|
65
|
+
if (!markSeenWithTtl(seen, key, Date.now(), completionTtlMs)) {
|
|
66
|
+
events.emit(eventName, payload);
|
|
58
67
|
}
|
|
59
68
|
try {
|
|
60
69
|
fs.unlinkSync(filePath);
|
|
@@ -62,7 +71,22 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
62
71
|
logInternalError("result-watcher.unlink", error, `filePath=${filePath}`);
|
|
63
72
|
}
|
|
64
73
|
}, 50);
|
|
65
|
-
const
|
|
74
|
+
const poll = () => {
|
|
75
|
+
if (!isCurrent() || !fs.existsSync(resultsDir)) return;
|
|
76
|
+
for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
|
|
77
|
+
};
|
|
78
|
+
const startPolling = () => {
|
|
79
|
+
if (pollTimer) return;
|
|
80
|
+
pollTimer = setInterval(poll, RESULT_WATCHER_POLL_MS);
|
|
81
|
+
pollTimer.unref?.();
|
|
82
|
+
poll();
|
|
83
|
+
};
|
|
84
|
+
const stopPolling = () => {
|
|
85
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
86
|
+
pollTimer = undefined;
|
|
87
|
+
};
|
|
88
|
+
const scheduleRestart = (error?: unknown) => {
|
|
89
|
+
if (shouldFallBackToPolling(error)) startPolling();
|
|
66
90
|
if (restartTimer) clearTimeout(restartTimer);
|
|
67
91
|
restartTimer = setTimeout(() => {
|
|
68
92
|
restartTimer = undefined;
|
|
@@ -85,17 +109,18 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
|
|
|
85
109
|
if (event !== "rename" || !fileName) return;
|
|
86
110
|
coalescer.schedule(fileName.toString());
|
|
87
111
|
}, scheduleRestart);
|
|
112
|
+
if (watcher) stopPolling();
|
|
88
113
|
watcher?.unref?.();
|
|
89
114
|
},
|
|
90
115
|
prime() {
|
|
91
|
-
|
|
92
|
-
for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
|
|
116
|
+
poll();
|
|
93
117
|
},
|
|
94
118
|
stop() {
|
|
95
119
|
if (restartTimer) clearTimeout(restartTimer);
|
|
96
120
|
restartTimer = undefined;
|
|
97
121
|
closeWatcher(watcher);
|
|
98
122
|
watcher = undefined;
|
|
123
|
+
stopPolling();
|
|
99
124
|
coalescer.clear();
|
|
100
125
|
},
|
|
101
126
|
};
|
|
@@ -5,7 +5,7 @@ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } f
|
|
|
5
5
|
import { withRunLockSync } from "../../state/locks.ts";
|
|
6
6
|
import { canTransitionTaskStatus, isTeamTaskStatus } from "../../state/contracts.ts";
|
|
7
7
|
import { claimTask, releaseTaskClaim, transitionClaimedTaskStatus } from "../../state/task-claims.ts";
|
|
8
|
-
import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, readMailbox, validateMailbox, type MailboxDirection } from "../../state/mailbox.ts";
|
|
8
|
+
import { acknowledgeMailboxMessage, appendMailboxMessage, readDeliveryState, readMailbox, readMailboxMessage, validateMailbox, type MailboxDirection } from "../../state/mailbox.ts";
|
|
9
9
|
import { appendEvent, readEvents, readEventsCursor } from "../../state/event-log.ts";
|
|
10
10
|
import { resolveCrewRuntime } from "../../runtime/runtime-resolver.ts";
|
|
11
11
|
import { probeLiveSessionRuntime } from "../../subagents/live/session-runtime.ts";
|
|
@@ -298,8 +298,18 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
298
298
|
if (!messageId) return result("API ack-message requires config.messageId.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
|
|
299
299
|
try {
|
|
300
300
|
return withRunLockSync(loaded.manifest, () => {
|
|
301
|
+
const message = readMailboxMessage(loaded.manifest, messageId);
|
|
301
302
|
const delivery = acknowledgeMailboxMessage(loaded.manifest, messageId);
|
|
302
303
|
appendEvent(loaded.manifest.eventsPath, { type: "mailbox.acknowledged", runId: loaded.manifest.runId, data: { messageId } });
|
|
304
|
+
if (message?.data?.kind === "group_join" && typeof message.data.requestId === "string") {
|
|
305
|
+
appendEvent(loaded.manifest.eventsPath, {
|
|
306
|
+
type: "agent.group_join.acknowledged",
|
|
307
|
+
runId: loaded.manifest.runId,
|
|
308
|
+
message: "Group join delivery acknowledged via mailbox ack.",
|
|
309
|
+
data: { requestId: message.data.requestId, messageId, batchId: message.data.batchId, partial: message.data.partial, acknowledgedAt: delivery.updatedAt, acknowledgedBy: "leader" },
|
|
310
|
+
metadata: { provenance: "api" },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
303
313
|
ctx.events?.emit?.("crew.mailbox.acknowledged", { runId: loaded.manifest.runId, messageId, delivery });
|
|
304
314
|
return result(JSON.stringify(delivery, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
|
|
305
315
|
});
|
|
@@ -10,6 +10,7 @@ import { DEFAULT_PATHS } from "../../config/defaults.ts";
|
|
|
10
10
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
11
11
|
import { getPiSpawnCommand } from "../../runtime/pi-spawn.ts";
|
|
12
12
|
import { validateResources } from "../validate-resources.ts";
|
|
13
|
+
import { TeamToolParams } from "../../schema/team-tool-schema.ts";
|
|
13
14
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
14
15
|
import { configRecord, result, type TeamContext } from "./context.ts";
|
|
15
16
|
|
|
@@ -59,6 +60,24 @@ function checkWritableDir(dir: string): { ok: boolean; detail: string } {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function auditJsonSchema(schema: unknown): string[] {
|
|
64
|
+
const issues: string[] = [];
|
|
65
|
+
const walk = (node: unknown): void => {
|
|
66
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
67
|
+
const record = node as Record<string, unknown>;
|
|
68
|
+
if (Array.isArray(record.type)) issues.push("schema node uses array-valued type");
|
|
69
|
+
if (record.description && !record.type && !record.anyOf && !record.oneOf && !record.allOf && !record.properties) issues.push(`description-only schema node: ${record.description}`);
|
|
70
|
+
if (record.type === "array" && !record.items) issues.push("array schema missing items");
|
|
71
|
+
if (record.type && (record.anyOf || record.oneOf)) issues.push("schema node combines type with union keyword");
|
|
72
|
+
for (const value of Object.values(record)) {
|
|
73
|
+
if (Array.isArray(value)) for (const item of value) walk(item);
|
|
74
|
+
else walk(value);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(schema);
|
|
78
|
+
return issues;
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
function makeLine(check: DoctorCheck): string {
|
|
63
82
|
return `- ${check.ok ? "OK" : "FAIL"} ${check.label}: ${check.detail}`;
|
|
64
83
|
}
|
|
@@ -130,6 +149,18 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
|
|
|
130
149
|
ok: input.validationErrors === 0,
|
|
131
150
|
detail: `${input.validationErrors} errors, ${input.validationWarnings} warnings`,
|
|
132
151
|
}]),
|
|
152
|
+
section("Schema", () => {
|
|
153
|
+
const schemaIssues = auditJsonSchema(TeamToolParams);
|
|
154
|
+
return [{ label: "strict-provider schema", ok: schemaIssues.length === 0, detail: schemaIssues.length ? schemaIssues.slice(0, 3).join("; ") : "team tool schema compatible" }];
|
|
155
|
+
}),
|
|
156
|
+
section("Async/result delivery", () => [
|
|
157
|
+
{ label: "result watcher", ok: true, detail: "fs.watch with polling fallback for EMFILE/ENOSPC/EPERM" },
|
|
158
|
+
{ label: "async notifier", ok: true, detail: "session-stale guarded completion notifications enabled" },
|
|
159
|
+
]),
|
|
160
|
+
section("Worktrees", () => [
|
|
161
|
+
{ label: "leader repository", ok: true, detail: input.cwd },
|
|
162
|
+
{ label: "cleanup policy", ok: true, detail: "dirty worktrees preserved unless force is set" },
|
|
163
|
+
]),
|
|
133
164
|
];
|
|
134
165
|
if (input.smokeChildPi) {
|
|
135
166
|
sections.push([`Child check`, `- ${input.smokeChildPi.ok ? "OK" : "FAIL"} child Pi smoke: ${input.smokeChildPi.detail}`]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/config.ts";
|
|
2
2
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
3
3
|
import { appendEvent, readEvents } from "../../state/event-log.ts";
|
|
4
|
+
import { readDeliveryState, readMailbox } from "../../state/mailbox.ts";
|
|
4
5
|
import { loadRunManifestById, updateRunStatus } from "../../state/state-store.ts";
|
|
5
6
|
import { aggregateUsage, formatUsage } from "../../state/usage.ts";
|
|
6
7
|
import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
|
|
@@ -27,15 +28,32 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
27
28
|
}
|
|
28
29
|
const counts = new Map<string, number>();
|
|
29
30
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
30
|
-
const
|
|
31
|
+
const allEvents = readEvents(manifest.eventsPath);
|
|
32
|
+
const events = allEvents.slice(-8);
|
|
33
|
+
const attentionByTask = new Map(allEvents.filter((event) => event.type === "task.attention" && event.taskId).map((event) => [event.taskId!, event]));
|
|
31
34
|
const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
|
|
32
35
|
const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
|
|
33
36
|
const artifactLines = manifest.artifacts.slice(-10).map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`);
|
|
37
|
+
const deliveryState = readDeliveryState(manifest);
|
|
38
|
+
const ackTimeoutMs = loadConfig(ctx.cwd).config.runtime?.groupJoinAckTimeoutMs;
|
|
39
|
+
const groupJoinLines = readMailbox(manifest, "outbox")
|
|
40
|
+
.filter((message) => message.data?.kind === "group_join")
|
|
41
|
+
.slice(-5)
|
|
42
|
+
.map((message) => {
|
|
43
|
+
const ack = deliveryState.messages[message.id] === "acknowledged" ? "acknowledged" : "pending";
|
|
44
|
+
const ageMs = Date.now() - new Date(message.createdAt).getTime();
|
|
45
|
+
const requestId = String(message.data?.requestId ?? "unknown");
|
|
46
|
+
const timedOut = ack === "pending" && ackTimeoutMs !== undefined && Number.isFinite(ageMs) && ageMs > ackTimeoutMs;
|
|
47
|
+
if (timedOut && !allEvents.some((event) => event.type === "agent.group_join.ack_timeout" && event.data?.requestId === requestId)) {
|
|
48
|
+
appendEvent(manifest.eventsPath, { type: "agent.group_join.ack_timeout", runId: manifest.runId, message: "Group join delivery ack timed out; mailbox delivery remains the fallback.", data: { requestId, messageId: message.id, batchId: message.data?.batchId, partial: message.data?.partial, ageMs, ackTimeoutMs } });
|
|
49
|
+
}
|
|
50
|
+
return `- ${String(message.data?.partial) === "true" ? "partial" : "completed"} request=${requestId} message=${message.id} ack=${timedOut ? "timeout" : ack}`;
|
|
51
|
+
});
|
|
34
52
|
const totalUsage = aggregateUsage(tasks);
|
|
35
53
|
const activeAgents = crewAgents.filter((agent) => agent.status === "running");
|
|
36
54
|
const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
|
|
37
55
|
const waitingTasks = tasks.filter((task) => task.status === "queued");
|
|
38
|
-
const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState
|
|
56
|
+
const agentLine = (agent: typeof crewAgents[number]): string => `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
|
|
39
57
|
const lines = [
|
|
40
58
|
`Run: ${manifest.runId}`,
|
|
41
59
|
`Team: ${manifest.team}`,
|
|
@@ -51,7 +69,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
51
69
|
"Task graph:",
|
|
52
70
|
...formatTaskGraphLines(tasks),
|
|
53
71
|
"Tasks:",
|
|
54
|
-
...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
|
|
72
|
+
...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${task.agentProgress.activityState}` : ""}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${String(attentionByTask.get(task.id)?.data?.reason)}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
|
|
55
73
|
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
56
74
|
"Active agents:",
|
|
57
75
|
...(activeAgents.length ? activeAgents.map(agentLine) : ["- (none)"]),
|
|
@@ -62,6 +80,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
62
80
|
"Policy decisions:",
|
|
63
81
|
...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
|
|
64
82
|
`Total usage: ${formatUsage(totalUsage)}`,
|
|
83
|
+
"Group joins:",
|
|
84
|
+
...(groupJoinLines.length ? groupJoinLines : ["- (none)"]),
|
|
65
85
|
"",
|
|
66
86
|
"Recent artifacts:",
|
|
67
87
|
...(artifactLines.length ? artifactLines : ["- (none)"]),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PiTeamsConfig } from "../config/config.ts";
|
|
2
2
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { appendTaskAttentionEvent } from "./attention-events.ts";
|
|
4
4
|
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
5
5
|
import { upsertCrewAgent } from "./crew-agent-records.ts";
|
|
6
6
|
|
|
@@ -53,12 +53,11 @@ export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentR
|
|
|
53
53
|
},
|
|
54
54
|
};
|
|
55
55
|
upsertCrewAgent(manifest, updated);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
runId: manifest.runId,
|
|
56
|
+
appendTaskAttentionEvent({
|
|
57
|
+
manifest,
|
|
59
58
|
taskId: agent.taskId,
|
|
60
59
|
message: `${agent.agent} needs attention (no observed activity for ${Math.floor(age / 1000)}s).`,
|
|
61
|
-
data: {
|
|
60
|
+
data: { activityState: "needs_attention", reason: "idle", elapsedMs: age, taskId: agent.taskId, agentName: agent.agent, suggestedAction: "Check worker status, wait, steer, or cancel if needed." },
|
|
62
61
|
});
|
|
63
62
|
return updated;
|
|
64
63
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { appendEvent, readEvents } from "../state/event-log.ts";
|
|
2
|
+
import type { CrewAttentionEventData, TeamRunManifest } from "../state/types.ts";
|
|
3
|
+
|
|
4
|
+
export interface AppendTaskAttentionInput {
|
|
5
|
+
manifest: TeamRunManifest;
|
|
6
|
+
taskId?: string;
|
|
7
|
+
message: string;
|
|
8
|
+
data: CrewAttentionEventData;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function appendTaskAttentionEvent(input: AppendTaskAttentionInput): boolean {
|
|
12
|
+
const recent = readEvents(input.manifest.eventsPath).slice(-100);
|
|
13
|
+
const duplicate = recent.some((event) => event.type === "task.attention" && event.taskId === input.taskId && event.data?.reason === input.data.reason && event.data?.activityState === input.data.activityState);
|
|
14
|
+
if (duplicate) return false;
|
|
15
|
+
appendEvent(input.manifest.eventsPath, {
|
|
16
|
+
type: "task.attention",
|
|
17
|
+
runId: input.manifest.runId,
|
|
18
|
+
taskId: input.taskId,
|
|
19
|
+
message: input.message,
|
|
20
|
+
data: { ...input.data },
|
|
21
|
+
});
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface CompletionMutationGuardInput {
|
|
4
|
+
role: string;
|
|
5
|
+
taskText?: string;
|
|
6
|
+
transcriptPath?: string;
|
|
7
|
+
stdout?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompletionMutationGuardResult {
|
|
11
|
+
expectedMutation: boolean;
|
|
12
|
+
observedMutation: boolean;
|
|
13
|
+
reason?: "no_mutation_observed";
|
|
14
|
+
observedTools: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MUTATING_ROLES = new Set(["executor", "test-engineer"]);
|
|
18
|
+
const MUTATING_TOOLS = new Set(["edit", "write", "multi_edit", "apply_patch"]);
|
|
19
|
+
const READ_ONLY_COMMANDS = /^(pwd|ls|dir|cat|type|sed|grep|rg|find|git\s+(status|diff|log|show|branch|remote|rev-parse|ls-files)|npm\s+(test|run\s+(typecheck|check|lint|test|ci))|node\s+--test)\b/i;
|
|
20
|
+
const MUTATING_COMMANDS = /\b(rm\s+-|del\s+|erase\s+|mv\s+|move\s+|cp\s+|copy\s+|mkdir\b|touch\b|git\s+(add|commit|push|reset|clean|checkout|switch|merge|rebase|stash)|npm\s+(install|i|uninstall|publish|version)|pnpm\s+(add|install|remove)|yarn\s+(add|install|remove)|python\b.*>|node\b.*>|echo\b.*>|Set-Content|Out-File)\b/i;
|
|
21
|
+
const READ_ONLY_HINTS = /\b(read-only|no edits?|do not edit|không sửa|khong sua|chỉ đọc|chi doc|plan only|chỉ lập plan|review only|audit only)\b/i;
|
|
22
|
+
|
|
23
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
24
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commandText(value: unknown): string {
|
|
28
|
+
const record = asRecord(value);
|
|
29
|
+
if (!record) return typeof value === "string" ? value : "";
|
|
30
|
+
for (const key of ["command", "cmd", "script", "input"]) {
|
|
31
|
+
const raw = record[key];
|
|
32
|
+
if (typeof raw === "string") return raw;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(record);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isMutatingTool(tool: string, args: unknown): boolean {
|
|
38
|
+
const normalized = tool.toLowerCase();
|
|
39
|
+
if (MUTATING_TOOLS.has(normalized)) return true;
|
|
40
|
+
if (normalized === "bash" || normalized === "shell" || normalized === "powershell") {
|
|
41
|
+
const command = commandText(args).trim();
|
|
42
|
+
if (!command || READ_ONLY_COMMANDS.test(command)) return false;
|
|
43
|
+
return MUTATING_COMMANDS.test(command);
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
|
|
49
|
+
const record = asRecord(event);
|
|
50
|
+
if (!record) return [];
|
|
51
|
+
const calls: Array<{ tool: string; args?: unknown }> = [];
|
|
52
|
+
const directTool = record.toolName ?? record.name ?? record.tool;
|
|
53
|
+
if (typeof directTool === "string" && (record.type === "tool_execution_start" || record.type === "toolCall" || record.type === "tool_call")) {
|
|
54
|
+
calls.push({ tool: directTool, args: record.args ?? record.input });
|
|
55
|
+
}
|
|
56
|
+
const content = Array.isArray(record.content) ? record.content : asRecord(record.message)?.content;
|
|
57
|
+
if (Array.isArray(content)) {
|
|
58
|
+
for (const part of content) {
|
|
59
|
+
const item = asRecord(part);
|
|
60
|
+
if (!item) continue;
|
|
61
|
+
const tool = item.name ?? item.toolName ?? item.tool;
|
|
62
|
+
if (typeof tool === "string" && (item.type === "toolCall" || item.type === "tool_call" || item.type === "tool_execution_start")) calls.push({ tool, args: item.input ?? item.args });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return calls;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function transcriptText(input: CompletionMutationGuardInput): string {
|
|
69
|
+
if (input.transcriptPath && fs.existsSync(input.transcriptPath)) return fs.readFileSync(input.transcriptPath, "utf-8");
|
|
70
|
+
return input.stdout ?? "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function expectsImplementationMutation(input: Pick<CompletionMutationGuardInput, "role" | "taskText">): boolean {
|
|
74
|
+
if (!MUTATING_ROLES.has(input.role)) return false;
|
|
75
|
+
return !READ_ONLY_HINTS.test(input.taskText ?? "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
|
|
79
|
+
const expectedMutation = expectsImplementationMutation(input);
|
|
80
|
+
const observedTools: string[] = [];
|
|
81
|
+
let observedMutation = false;
|
|
82
|
+
const text = transcriptText(input);
|
|
83
|
+
for (const line of text.split("\n")) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
if (!trimmed) continue;
|
|
86
|
+
let event: unknown;
|
|
87
|
+
try { event = JSON.parse(trimmed); } catch { continue; }
|
|
88
|
+
for (const call of collectToolCallsFromEvent(event)) {
|
|
89
|
+
observedTools.push(call.tool);
|
|
90
|
+
if (isMutatingTool(call.tool, call.args)) observedMutation = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
expectedMutation,
|
|
95
|
+
observedMutation,
|
|
96
|
+
observedTools,
|
|
97
|
+
...(expectedMutation && !observedMutation ? { reason: "no_mutation_observed" as const } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TeamTaskStatus } from "../state/contracts.ts";
|
|
2
|
-
import type { ModelRoutingState, UsageState } from "../state/types.ts";
|
|
2
|
+
import type { CrewActivityState, ModelRoutingState, UsageState } from "../state/types.ts";
|
|
3
3
|
|
|
4
4
|
export type CrewRuntimeKind = "scaffold" | "child-process" | "live-session";
|
|
5
5
|
export type CrewAgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped";
|
|
@@ -21,7 +21,7 @@ export interface CrewAgentProgress {
|
|
|
21
21
|
turns?: number;
|
|
22
22
|
durationMs?: number;
|
|
23
23
|
lastActivityAt?: string;
|
|
24
|
-
activityState?:
|
|
24
|
+
activityState?: CrewActivityState;
|
|
25
25
|
failedTool?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
2
2
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
3
3
|
import { appendEvent } from "../state/event-log.ts";
|
|
4
|
-
import { appendMailboxMessage } from "../state/mailbox.ts";
|
|
4
|
+
import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState } from "../state/mailbox.ts";
|
|
5
5
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
6
|
import { aggregateTaskOutputs } from "./task-output-context.ts";
|
|
7
7
|
|
|
@@ -18,6 +18,9 @@ export interface CrewGroupJoinDelivery {
|
|
|
18
18
|
remaining: string[];
|
|
19
19
|
artifact?: ArtifactDescriptor;
|
|
20
20
|
messageId?: string;
|
|
21
|
+
requestId?: string;
|
|
22
|
+
ackRequired?: boolean;
|
|
23
|
+
ackStatus?: "pending" | "acknowledged";
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function resolveGroupJoinMode(runtime?: CrewRuntimeConfig): CrewGroupJoinMode {
|
|
@@ -34,6 +37,10 @@ function batchIdFor(runId: string, taskIds: string[]): string {
|
|
|
34
37
|
return `${runId}_${taskIds.join("+").replace(/[^a-zA-Z0-9_+-]/g, "_")}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
function requestIdFor(runId: string, batchId: string, partial: boolean): string {
|
|
41
|
+
return `${runId}:group-join:${partial ? "partial" : "completed"}:${batchId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function statusList(tasks: TeamTaskState[], status: TeamTaskState["status"]): string[] {
|
|
38
45
|
return tasks.filter((task) => task.status === status).map((task) => task.id);
|
|
39
46
|
}
|
|
@@ -55,7 +62,10 @@ export function deliverGroupJoin(input: {
|
|
|
55
62
|
const partial = input.partial ?? remaining.length > 0;
|
|
56
63
|
const batchId = batchIdFor(input.manifest.runId, taskIds);
|
|
57
64
|
const summary = aggregateTaskOutputs(latest, input.manifest);
|
|
58
|
-
const
|
|
65
|
+
const requestId = requestIdFor(input.manifest.runId, batchId, partial);
|
|
66
|
+
const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
|
|
67
|
+
const existingStatus = existingMailbox ? readDeliveryState(input.manifest).messages[existingMailbox.id] ?? existingMailbox.status : undefined;
|
|
68
|
+
const delivery: CrewGroupJoinDelivery = { batchId, mode: input.mode, partial, taskIds, completed, failed, skipped, remaining, requestId, ackRequired: true, ackStatus: existingStatus === "acknowledged" ? "acknowledged" : "pending" };
|
|
59
69
|
const content = `${JSON.stringify({ ...delivery, createdAt: new Date().toISOString() }, null, 2)}\n`;
|
|
60
70
|
const artifact = writeArtifact(input.manifest.artifactsRoot, {
|
|
61
71
|
kind: "metadata",
|
|
@@ -63,12 +73,13 @@ export function deliverGroupJoin(input: {
|
|
|
63
73
|
producer: "group-join",
|
|
64
74
|
content,
|
|
65
75
|
});
|
|
66
|
-
const mailbox = appendMailboxMessage(input.manifest, {
|
|
76
|
+
const mailbox = existingMailbox ?? appendMailboxMessage(input.manifest, {
|
|
67
77
|
direction: "outbox",
|
|
68
78
|
from: "group-join",
|
|
69
79
|
to: "leader",
|
|
70
80
|
body: [
|
|
71
81
|
`Group join ${partial ? "partial" : "completed"}: ${taskIds.join(", ")}`,
|
|
82
|
+
`Request: ${requestId}`,
|
|
72
83
|
`Completed: ${completed.join(", ") || "none"}`,
|
|
73
84
|
`Failed: ${failed.join(", ") || "none"}`,
|
|
74
85
|
`Skipped: ${skipped.join(", ") || "none"}`,
|
|
@@ -77,12 +88,19 @@ export function deliverGroupJoin(input: {
|
|
|
77
88
|
summary,
|
|
78
89
|
].join("\n"),
|
|
79
90
|
status: "delivered",
|
|
91
|
+
data: { kind: "group_join", requestId, batchId, partial, ackRequired: true, taskIds, completed, failed, skipped, remaining },
|
|
80
92
|
});
|
|
81
93
|
appendEvent(input.manifest.eventsPath, {
|
|
82
94
|
type: partial ? "agent.group_join.partial" : "agent.group_join.completed",
|
|
83
95
|
runId: input.manifest.runId,
|
|
84
96
|
message: `Group join ${partial ? "partial" : "completed"} for ${taskIds.length} task(s).`,
|
|
85
|
-
data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id },
|
|
97
|
+
data: { ...delivery, artifactPath: artifact.path, messageId: mailbox.id, fallback: "mailbox-delivered", reused: Boolean(existingMailbox) },
|
|
98
|
+
});
|
|
99
|
+
if (existingMailbox) appendEvent(input.manifest.eventsPath, {
|
|
100
|
+
type: "agent.group_join.delivery_reused",
|
|
101
|
+
runId: input.manifest.runId,
|
|
102
|
+
message: `Reused group join mailbox delivery for ${taskIds.length} task(s).`,
|
|
103
|
+
data: { requestId, messageId: mailbox.id, batchId, partial },
|
|
86
104
|
});
|
|
87
105
|
return { ...delivery, artifact, messageId: mailbox.id };
|
|
88
106
|
}
|
|
@@ -25,6 +25,8 @@ import { coordinationBridgeInstructions, renderTaskPrompt } from "./task-runner/
|
|
|
25
25
|
import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./task-runner/progress.ts";
|
|
26
26
|
import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./task-runner/state-helpers.ts";
|
|
27
27
|
import { cleanResultText, isFinalChildEvent } from "./task-runner/result-utils.ts";
|
|
28
|
+
import { evaluateCompletionMutationGuard } from "./completion-guard.ts";
|
|
29
|
+
import { appendTaskAttentionEvent } from "./attention-events.ts";
|
|
28
30
|
|
|
29
31
|
export interface TaskRunnerInput {
|
|
30
32
|
manifest: TeamRunManifest;
|
|
@@ -86,6 +88,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
86
88
|
let error: string | undefined;
|
|
87
89
|
let modelAttempts: ModelAttemptSummary[] | undefined;
|
|
88
90
|
let parsedOutput: ParsedPiJsonOutput | undefined;
|
|
91
|
+
let finalStdout = "";
|
|
92
|
+
let transcriptPath: string | undefined;
|
|
89
93
|
|
|
90
94
|
let startupEvidence = createStartupEvidence({ command: runtimeKind === "child-process" ? "pi" : runtimeKind === "live-session" ? "live-session" : "safe-scaffold", startedAt: new Date(task.startedAt ?? new Date().toISOString()), finishedAt: new Date(), promptSentAt: new Date(task.startedAt ?? new Date().toISOString()), promptAccepted: true, exitCode: 0 });
|
|
91
95
|
const inputsArtifact = writeTaskInputsArtifact(manifest, task, dependencyContext);
|
|
@@ -100,10 +104,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
100
104
|
const candidates = modelRoutingPlan.candidates;
|
|
101
105
|
const attemptModels = candidates.length > 0 ? candidates : [undefined];
|
|
102
106
|
const logs: string[] = [];
|
|
103
|
-
let finalStdout = "";
|
|
104
107
|
let finalStderr = "";
|
|
105
108
|
modelAttempts = [];
|
|
106
|
-
|
|
109
|
+
transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
107
110
|
let finalCheckpointWritten = false;
|
|
108
111
|
let lastAgentRecordPersistedAt = 0;
|
|
109
112
|
let lastHeartbeatPersistedAt = 0;
|
|
@@ -258,6 +261,26 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
258
261
|
producer: task.id,
|
|
259
262
|
}) : undefined;
|
|
260
263
|
|
|
264
|
+
const mutationGuardMode = input.runtimeConfig?.completionMutationGuard ?? "warn";
|
|
265
|
+
const mutationGuard = !error && mutationGuardMode !== "off" ? evaluateCompletionMutationGuard({ role: task.role, taskText: `${task.title}\n${input.step.task}`, transcriptPath: runtimeKind === "child-process" ? transcriptPath : transcriptArtifact?.path, stdout: finalStdout }) : undefined;
|
|
266
|
+
if (mutationGuard?.reason === "no_mutation_observed") {
|
|
267
|
+
appendTaskAttentionEvent({
|
|
268
|
+
manifest,
|
|
269
|
+
taskId: task.id,
|
|
270
|
+
message: "Implementation-style task completed without an observed mutation tool call.",
|
|
271
|
+
data: { activityState: "needs_attention", reason: "completion_guard", taskId: task.id, agentName: task.agent, observedTools: mutationGuard.observedTools, suggestedAction: mutationGuardMode === "fail" ? "Review the worker output and rerun with a concrete implementation task." : "Review the worker output; set runtime.completionMutationGuard='fail' to enforce this." },
|
|
272
|
+
});
|
|
273
|
+
task = { ...task, agentProgress: { ...(task.agentProgress ?? emptyCrewAgentProgress()), activityState: "needs_attention" } };
|
|
274
|
+
if (mutationGuardMode === "fail") {
|
|
275
|
+
error = "Completion mutation guard failed: implementation-style task completed without an observed mutation tool call.";
|
|
276
|
+
exitCode = exitCode === 0 ? 1 : exitCode;
|
|
277
|
+
if (modelAttempts?.length) {
|
|
278
|
+
modelAttempts = modelAttempts.map((attempt, index) => index === modelAttempts!.length - 1 ? { ...attempt, success: false, exitCode, error } : attempt);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
tasks = updateTask(tasks, task);
|
|
282
|
+
}
|
|
283
|
+
|
|
261
284
|
task = {
|
|
262
285
|
...task,
|
|
263
286
|
status: error ? "failed" : "completed",
|
|
@@ -118,8 +118,11 @@ function slug(value: string): string {
|
|
|
118
118
|
|
|
119
119
|
function extractAdaptivePlanJson(text: string): string | undefined {
|
|
120
120
|
const markerMatch = text.match(/ADAPTIVE_PLAN_JSON_START\s*([\s\S]*?)\s*ADAPTIVE_PLAN_JSON_END/);
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if (markerMatch?.[1]) return markerMatch[1];
|
|
122
|
+
const startIndex = text.indexOf("ADAPTIVE_PLAN_JSON_START");
|
|
123
|
+
if (startIndex >= 0) return text.slice(startIndex + "ADAPTIVE_PLAN_JSON_START".length).trim();
|
|
124
|
+
const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
125
|
+
return fencedMatch?.[1];
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
export function __test__parseAdaptivePlan(text: string, allowedRoles: string[]): AdaptivePlan | undefined {
|
|
@@ -179,6 +182,52 @@ function closeUnbalancedJson(raw: string): string {
|
|
|
179
182
|
return result;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
function salvageCompletePhaseObjects(raw: string): unknown | undefined {
|
|
186
|
+
const phasesIndex = raw.indexOf('"phases"');
|
|
187
|
+
if (phasesIndex < 0) return undefined;
|
|
188
|
+
const arrayStart = raw.indexOf("[", phasesIndex);
|
|
189
|
+
if (arrayStart < 0) return undefined;
|
|
190
|
+
const phases: unknown[] = [];
|
|
191
|
+
let objectStart = -1;
|
|
192
|
+
let depth = 0;
|
|
193
|
+
let inString = false;
|
|
194
|
+
let escaped = false;
|
|
195
|
+
for (let index = arrayStart + 1; index < raw.length; index++) {
|
|
196
|
+
const char = raw[index];
|
|
197
|
+
if (escaped) {
|
|
198
|
+
escaped = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (char === "\\" && inString) {
|
|
202
|
+
escaped = true;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (char === '"') {
|
|
206
|
+
inString = !inString;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (inString) continue;
|
|
210
|
+
if (char === "{") {
|
|
211
|
+
if (depth === 0) objectStart = index;
|
|
212
|
+
depth++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (char === "}") {
|
|
216
|
+
if (depth <= 0) continue;
|
|
217
|
+
depth--;
|
|
218
|
+
if (depth === 0 && objectStart >= 0) {
|
|
219
|
+
try {
|
|
220
|
+
phases.push(JSON.parse(raw.slice(objectStart, index + 1)));
|
|
221
|
+
} catch {
|
|
222
|
+
// Ignore malformed trailing phase objects and keep earlier complete phases.
|
|
223
|
+
}
|
|
224
|
+
objectStart = -1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return phases.length ? { phases } : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
182
231
|
function adaptiveRoleAlias(role: string, allowed: Set<string>): string | undefined {
|
|
183
232
|
if (allowed.has(role)) return role;
|
|
184
233
|
const normalized = slug(role);
|
|
@@ -199,6 +248,7 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
|
|
|
199
248
|
if (!raw) return { repaired: false, reason: "missing-json" };
|
|
200
249
|
const candidates = [raw, closeUnbalancedJson(raw)];
|
|
201
250
|
let parsed: unknown;
|
|
251
|
+
let salvageUsed = false;
|
|
202
252
|
for (const candidate of candidates) {
|
|
203
253
|
try {
|
|
204
254
|
parsed = JSON.parse(candidate);
|
|
@@ -207,13 +257,17 @@ export function __test__repairAdaptivePlan(text: string, allowedRoles: string[])
|
|
|
207
257
|
// Try the next repair candidate.
|
|
208
258
|
}
|
|
209
259
|
}
|
|
260
|
+
if (!parsed) {
|
|
261
|
+
parsed = salvageCompletePhaseObjects(raw);
|
|
262
|
+
salvageUsed = parsed !== undefined;
|
|
263
|
+
}
|
|
210
264
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { repaired: false, reason: "invalid-json" };
|
|
211
265
|
const phasesRaw = Array.isArray((parsed as { phases?: unknown }).phases) ? (parsed as { phases: unknown[] }).phases : Array.isArray((parsed as { tasks?: unknown }).tasks) ? [{ name: "adaptive", tasks: (parsed as { tasks: unknown[] }).tasks }] : undefined;
|
|
212
266
|
if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
|
|
213
267
|
const allowed = new Set(allowedRoles);
|
|
214
268
|
const phases: AdaptivePlanPhase[] = [];
|
|
215
269
|
let total = 0;
|
|
216
|
-
let repaired = raw !== closeUnbalancedJson(raw);
|
|
270
|
+
let repaired = salvageUsed || raw !== closeUnbalancedJson(raw);
|
|
217
271
|
for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
|
|
218
272
|
if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
|
|
219
273
|
const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
|
|
@@ -36,7 +36,9 @@ export const PiTeamsRuntimeConfigSchema = Type.Object({
|
|
|
36
36
|
inheritContext: Type.Optional(Type.Boolean()),
|
|
37
37
|
promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
|
|
38
38
|
groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
|
|
39
|
+
groupJoinAckTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
39
40
|
requirePlanApproval: Type.Optional(Type.Boolean()),
|
|
41
|
+
completionMutationGuard: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("warn"), Type.Literal("fail")])),
|
|
40
42
|
}, { additionalProperties: false });
|
|
41
43
|
|
|
42
44
|
export const PiTeamsControlConfigSchema = Type.Object({
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
|
|
3
3
|
const SkillOverride = Type.Unsafe({
|
|
4
|
-
type: ["string", "array", "boolean"],
|
|
5
|
-
items: { type: "string" },
|
|
6
4
|
description: "Skill name(s) to inject, array of skill names, or false to disable role defaults.",
|
|
5
|
+
anyOf: [
|
|
6
|
+
{ type: "string" },
|
|
7
|
+
{ type: "array", items: { type: "string" } },
|
|
8
|
+
{ type: "boolean" },
|
|
9
|
+
],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const FreeformConfig = Type.Unsafe({
|
|
13
|
+
description: "Resource config for management actions.",
|
|
14
|
+
type: "object",
|
|
15
|
+
additionalProperties: true,
|
|
7
16
|
});
|
|
8
17
|
|
|
9
18
|
export const TeamToolParams = Type.Object({
|
|
@@ -66,7 +75,7 @@ export const TeamToolParams = Type.Object({
|
|
|
66
75
|
Type.Literal("project"),
|
|
67
76
|
Type.Literal("both"),
|
|
68
77
|
], { description: "Resource scope for discovery or management." })),
|
|
69
|
-
config: Type.Optional(
|
|
78
|
+
config: Type.Optional(FreeformConfig),
|
|
70
79
|
dryRun: Type.Optional(Type.Boolean({ description: "Preview a management mutation without writing files." })),
|
|
71
80
|
confirm: Type.Optional(Type.Boolean({ description: "Required for destructive management actions." })),
|
|
72
81
|
force: Type.Optional(Type.Boolean({ description: "Override reference checks for destructive management actions." })),
|
package/src/state/mailbox.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface MailboxMessage {
|
|
|
18
18
|
status: MailboxMessageStatus;
|
|
19
19
|
taskId?: string;
|
|
20
20
|
acknowledgedAt?: string;
|
|
21
|
+
data?: Record<string, unknown>;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface MailboxDeliveryState {
|
|
@@ -134,7 +135,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
|
|
|
134
135
|
const obj = raw as Record<string, unknown>;
|
|
135
136
|
if (typeof obj.id !== "string" || typeof obj.runId !== "string" || !isDirection(obj.direction) || typeof obj.from !== "string" || typeof obj.to !== "string" || typeof obj.body !== "string" || typeof obj.createdAt !== "string" || !isStatus(obj.status)) return undefined;
|
|
136
137
|
if (obj.direction !== expectedDirection) return undefined;
|
|
137
|
-
return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined };
|
|
138
|
+
return { id: obj.id, runId: obj.runId, direction: obj.direction, from: obj.from, to: obj.to, body: obj.body, createdAt: obj.createdAt, status: obj.status, taskId: typeof obj.taskId === "string" ? obj.taskId : undefined, acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined, data: obj.data && typeof obj.data === "object" && !Array.isArray(obj.data) ? obj.data as Record<string, unknown> : undefined };
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
function readMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
|
|
@@ -208,6 +209,7 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
208
209
|
createdAt,
|
|
209
210
|
status: message.status ?? "queued",
|
|
210
211
|
taskId: message.taskId,
|
|
212
|
+
data: message.data,
|
|
211
213
|
};
|
|
212
214
|
fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
|
|
213
215
|
const delivery = readDeliveryState(manifest);
|
|
@@ -217,6 +219,14 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
217
219
|
return complete;
|
|
218
220
|
}
|
|
219
221
|
|
|
222
|
+
export function findMailboxMessageByRequestId(manifest: TeamRunManifest, requestId: string): MailboxMessage | undefined {
|
|
223
|
+
return readMailbox(manifest).find((message) => message.data?.requestId === requestId);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function readMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxMessage | undefined {
|
|
227
|
+
return readMailbox(manifest).find((message) => message.id === messageId);
|
|
228
|
+
}
|
|
229
|
+
|
|
220
230
|
export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
|
|
221
231
|
const delivery = readDeliveryState(manifest);
|
|
222
232
|
if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
|
package/src/state/types.ts
CHANGED
|
@@ -92,6 +92,19 @@ export interface PlanApprovalState {
|
|
|
92
92
|
planArtifactPath?: string;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export type CrewActivityState = "active" | "active_long_running" | "needs_attention" | "stale";
|
|
96
|
+
export type CrewAttentionReason = "idle" | "tool_failures" | "completion_guard" | "heartbeat_stale" | "plan_approval_pending";
|
|
97
|
+
|
|
98
|
+
export interface CrewAttentionEventData {
|
|
99
|
+
activityState: CrewActivityState;
|
|
100
|
+
reason: CrewAttentionReason;
|
|
101
|
+
elapsedMs?: number;
|
|
102
|
+
taskId?: string;
|
|
103
|
+
agentName?: string;
|
|
104
|
+
suggestedAction?: string;
|
|
105
|
+
observedTools?: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
95
108
|
export interface TeamRunManifest {
|
|
96
109
|
schemaVersion: 1;
|
|
97
110
|
runId: string;
|
|
@@ -3,8 +3,11 @@ import type { RunUiSnapshot } from "../snapshot-types.ts";
|
|
|
3
3
|
export function renderProgressPane(snapshot: RunUiSnapshot | undefined): string[] {
|
|
4
4
|
if (!snapshot) return ["Progress pane: snapshot unavailable"];
|
|
5
5
|
const progress = snapshot.progress;
|
|
6
|
+
const groupJoins = snapshot.groupJoins ?? [];
|
|
7
|
+
const groupJoinLines = groupJoins.length ? groupJoins.map((item) => `group join ${item.partial ? "partial" : "completed"}: ${item.requestId} ack=${item.ack}`) : ["group joins: none"];
|
|
6
8
|
return [
|
|
7
9
|
`Progress pane: ${progress.completed}/${progress.total} completed · running=${progress.running} queued=${progress.queued} failed=${progress.failed}`,
|
|
10
|
+
...groupJoinLines,
|
|
8
11
|
...snapshot.recentEvents.slice(-10).map((event) => {
|
|
9
12
|
const seq = event.metadata?.seq !== undefined ? `#${event.metadata.seq}` : "#?";
|
|
10
13
|
return `${seq} ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? ` · ${event.message}` : ""}`;
|
|
@@ -8,7 +8,7 @@ import { readEvents, type TeamEvent } from "../state/event-log.ts";
|
|
|
8
8
|
import type { MailboxMessageStatus } from "../state/mailbox.ts";
|
|
9
9
|
import { loadRunManifestById } from "../state/state-store.ts";
|
|
10
10
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
11
|
-
import type { RunSnapshotCache, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
|
|
11
|
+
import type { RunSnapshotCache, RunUiGroupJoin, RunUiMailbox, RunUiProgress, RunUiSnapshot, RunUiUsage } from "./snapshot-types.ts";
|
|
12
12
|
|
|
13
13
|
const DEFAULT_TTL_MS = 250;
|
|
14
14
|
const DEFAULT_MAX_ENTRIES = 24;
|
|
@@ -195,6 +195,25 @@ function readDeliveryMessages(filePath: string): Record<string, MailboxMessageSt
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
function readGroupJoinMailbox(filePath: string, delivery: Record<string, MailboxMessageStatus>): RunUiGroupJoin[] {
|
|
199
|
+
try {
|
|
200
|
+
return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(line) as unknown;
|
|
203
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
204
|
+
const message = parsed as { id?: unknown; data?: unknown };
|
|
205
|
+
const data = message.data && typeof message.data === "object" && !Array.isArray(message.data) ? message.data as Record<string, unknown> : undefined;
|
|
206
|
+
if (typeof message.id !== "string" || data?.kind !== "group_join" || typeof data.requestId !== "string") return [];
|
|
207
|
+
return [{ requestId: data.requestId, messageId: message.id, partial: data.partial === true, ack: delivery[message.id] === "acknowledged" ? "acknowledged" as const : "pending" as const }];
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
198
217
|
function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMessageStatus>): number {
|
|
199
218
|
try {
|
|
200
219
|
return fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).reduce((count, line) => {
|
|
@@ -213,6 +232,12 @@ function readMailboxCounts(filePath: string, delivery: Record<string, MailboxMes
|
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
|
|
235
|
+
function groupJoinsFrom(manifest: TeamRunManifest): RunUiGroupJoin[] {
|
|
236
|
+
const root = path.join(manifest.stateRoot, "mailbox");
|
|
237
|
+
const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
|
|
238
|
+
return readGroupJoinMailbox(path.join(root, "outbox.jsonl"), delivery).slice(-5);
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
function mailboxFrom(manifest: TeamRunManifest, agents: CrewAgentRecord[]): RunUiMailbox {
|
|
217
242
|
const root = path.join(manifest.stateRoot, "mailbox");
|
|
218
243
|
const delivery = readDeliveryMessages(path.join(root, "delivery.json"));
|
|
@@ -241,6 +266,7 @@ function signatureFor(input: Omit<RunUiSnapshot, "signature" | "fetchedAt">, sta
|
|
|
241
266
|
progress: input.progress,
|
|
242
267
|
usage: input.usage,
|
|
243
268
|
mailbox: input.mailbox,
|
|
269
|
+
groupJoins: input.groupJoins,
|
|
244
270
|
events: input.recentEvents.map((event) => [event.metadata?.seq, event.time, event.type, event.taskId, event.message]),
|
|
245
271
|
output: input.recentOutputLines,
|
|
246
272
|
stamps,
|
|
@@ -306,6 +332,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
306
332
|
throw new Error(`Run '${runId}' could not be parsed.`);
|
|
307
333
|
}
|
|
308
334
|
const mailbox = mailboxFrom(loaded.manifest, agents);
|
|
335
|
+
const groupJoins = groupJoinsFrom(loaded.manifest);
|
|
309
336
|
const base = {
|
|
310
337
|
runId: loaded.manifest.runId,
|
|
311
338
|
cwd: loaded.manifest.cwd,
|
|
@@ -315,6 +342,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
315
342
|
progress: progressFromTasks(tasks),
|
|
316
343
|
usage: usageFrom(tasks, agents),
|
|
317
344
|
mailbox,
|
|
345
|
+
groupJoins,
|
|
318
346
|
recentEvents: safeRecentEvents(loaded.manifest.eventsPath, recentEventsLimit),
|
|
319
347
|
recentOutputLines: recentOutputLines(loaded.manifest, agents, recentOutputLimit),
|
|
320
348
|
};
|
package/src/ui/snapshot-types.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface RunUiMailbox {
|
|
|
22
22
|
needsAttention: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface RunUiGroupJoin {
|
|
26
|
+
requestId: string;
|
|
27
|
+
messageId: string;
|
|
28
|
+
partial: boolean;
|
|
29
|
+
ack: "pending" | "acknowledged";
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
export interface RunUiSnapshot {
|
|
26
33
|
runId: string;
|
|
27
34
|
cwd: string;
|
|
@@ -33,6 +40,7 @@ export interface RunUiSnapshot {
|
|
|
33
40
|
progress: RunUiProgress;
|
|
34
41
|
usage: RunUiUsage;
|
|
35
42
|
mailbox: RunUiMailbox;
|
|
43
|
+
groupJoins?: RunUiGroupJoin[];
|
|
36
44
|
recentEvents: TeamEvent[];
|
|
37
45
|
recentOutputLines: string[];
|
|
38
46
|
}
|
package/src/utils/fs-watch.ts
CHANGED
|
@@ -18,14 +18,14 @@ export function closeWatcher(watcher: FSWatcher | null | undefined): void {
|
|
|
18
18
|
export function watchWithErrorHandler(
|
|
19
19
|
path: string,
|
|
20
20
|
listener: WatchListener<string>,
|
|
21
|
-
onError: () => void,
|
|
21
|
+
onError: (error?: unknown) => void,
|
|
22
22
|
): FSWatcher | null {
|
|
23
23
|
try {
|
|
24
24
|
const watcher = fs.watch(path, listener);
|
|
25
25
|
watcher.on("error", onError);
|
|
26
26
|
return watcher;
|
|
27
|
-
} catch {
|
|
28
|
-
onError();
|
|
27
|
+
} catch (error) {
|
|
28
|
+
onError(error);
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
}
|