pi-crew 0.7.2 → 0.7.3
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 +26 -0
- package/package.json +1 -1
- package/src/extension/command-completions.ts +118 -0
- package/src/extension/crew-input-router.ts +63 -0
- package/src/extension/message-renderers.ts +109 -0
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/commands.ts +21 -8
- package/src/runtime/background-runner.ts +25 -26
- package/src/state/event-log-rotation.ts +29 -12
- package/src/state/mailbox.ts +32 -18
- package/src/state/observation-store.ts +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.3] — Reliability hardening + UX quick wins (2026-06-15)
|
|
4
|
+
|
|
5
|
+
This release fixes 4 critical data-loss bugs found by the Round 12 reliability audit and adds three UX quick wins from the Round 13 UX research (+125 tests from the Round 14 coverage sprint).
|
|
6
|
+
|
|
7
|
+
### Bug Fixes (Critical — data loss prevention)
|
|
8
|
+
|
|
9
|
+
- **`rotateEventLog` destroyed ALL events** — `atomicWriteFile("")` then `rename` replaced the file with empty content *before* the rename, so the archive received an empty file. Now copies content to archive first, then truncates in place. Also handles sub-millisecond timestamp collisions.
|
|
10
|
+
- **`compactEventLog` recovery loop replaced the file per-event** — each `atomicWriteFile` iteration overwrote the compacted log + previous recoveries, leaving only the last event. Now accumulates missing events into one `appendFileSync`.
|
|
11
|
+
- **Mailbox `delivery.json` lost-update race** — `appendMailboxMessage`, `acknowledgeMailboxMessage`, and `replayPendingMailboxMessages` all had unlocked read-modify-write cycles. Now wrapped in `withFileLockSync`.
|
|
12
|
+
- **`observation-store.save()` non-atomic write** — raw `writeFileSync` could leave a truncated file on crash. Now uses `atomicWriteJson`.
|
|
13
|
+
- **`background-runner` DEBUG log noise** — 10 trace-level `console.log` statements gated behind `PI_CREW_DEBUG` env var.
|
|
14
|
+
|
|
15
|
+
### Features (UX)
|
|
16
|
+
|
|
17
|
+
- **Command argument autocomplete** — 13 run-scoped and team-scoped commands now implement `getArgumentCompletions` so Pi's built-in Tab-completion surfaces run IDs (with status icon + goal preview), team names, workflow names, and task IDs. No more memorizing long generated run IDs.
|
|
18
|
+
- **Custom message renderers** — `crew:run-started`, `crew:run-completed`, and `crew:resume-directive` entries now render with a clean crew-branded look (🚀/✅/❌ status icons, theme colors) instead of raw JSON blobs.
|
|
19
|
+
- **Natural-language crew routing** — type `crew status`, `team dashboard`, `crew help`, `teams`, etc. and pi-crew rewrites it to the equivalent slash command. Only transforms interactive input; never shadows explicit slash commands.
|
|
20
|
+
|
|
21
|
+
### Tests
|
|
22
|
+
|
|
23
|
+
- +125 tests (4795 pass / 0 fail). New coverage: cascading replace engine (31), safe-paths traversal defense (21), atomic-write symlink prevention (15), command completions (20), message renderers (12), input router (18), event-log rotate regression (9).
|
|
24
|
+
|
|
25
|
+
### Research
|
|
26
|
+
|
|
27
|
+
This release was driven by 4 deep research rounds (11–14), documented in `research-findings/`.
|
|
28
|
+
|
|
3
29
|
## [0.7.2] — Fix: Knowledge Injection into Workers + HITL for All Workflows (2026-06-15)
|
|
4
30
|
|
|
5
31
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command argument autocomplete helpers (Round 13 UX quick-win).
|
|
3
|
+
*
|
|
4
|
+
* Pi's built-in slash-command autocomplete calls a command's
|
|
5
|
+
* `getArgumentCompletions(argumentPrefix)` when the user types
|
|
6
|
+
* `/command <prefix><TAB>`. Returning AutocompleteItem[] surfaces those
|
|
7
|
+
* suggestions; returning null falls back to file completion.
|
|
8
|
+
*
|
|
9
|
+
* These helpers provide run-id, team, and workflow completions without
|
|
10
|
+
* requiring the user to memorize long generated IDs.
|
|
11
|
+
*/
|
|
12
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
13
|
+
import { listRecentRuns } from "./run-index.ts";
|
|
14
|
+
import { discoverTeams, allTeams } from "../teams/discover-teams.ts";
|
|
15
|
+
import { discoverWorkflows, allWorkflows } from "../workflows/discover-workflows.ts";
|
|
16
|
+
import { discoverAgents, allAgents } from "../agents/discover-agents.ts";
|
|
17
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
18
|
+
|
|
19
|
+
const MAX_RUN_SUGGESTIONS = 15;
|
|
20
|
+
|
|
21
|
+
function filterByPrefix(items: AutocompleteItem[], prefix: string): AutocompleteItem[] | null {
|
|
22
|
+
const trimmed = prefix.trim();
|
|
23
|
+
const filtered = trimmed === ""
|
|
24
|
+
? items
|
|
25
|
+
: items.filter((item) => item.value.startsWith(trimmed) || item.label.toLowerCase().includes(trimmed.toLowerCase()));
|
|
26
|
+
return filtered.length > 0 ? filtered.slice(0, MAX_RUN_SUGGESTIONS) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusIcon(status: TeamRunManifest["status"]): string {
|
|
30
|
+
switch (status) {
|
|
31
|
+
case "running":
|
|
32
|
+
case "planning":
|
|
33
|
+
return "▶";
|
|
34
|
+
case "queued":
|
|
35
|
+
return "⏳";
|
|
36
|
+
case "completed":
|
|
37
|
+
return "✓";
|
|
38
|
+
case "failed":
|
|
39
|
+
case "blocked":
|
|
40
|
+
return "✗";
|
|
41
|
+
case "cancelled":
|
|
42
|
+
return "⊘";
|
|
43
|
+
default:
|
|
44
|
+
return "•";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Suggest recent run IDs for run-scoped commands (/team-status, /team-cancel, …).
|
|
50
|
+
* Falls back to `process.cwd()` because Pi does not pass cwd into
|
|
51
|
+
* `getArgumentCompletions` — this is correct in the interactive TUI where the
|
|
52
|
+
* process cwd matches the session cwd.
|
|
53
|
+
*/
|
|
54
|
+
export function suggestRunIds(_prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
55
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
56
|
+
const runs = listRecentRuns(resolvedCwd, MAX_RUN_SUGGESTIONS);
|
|
57
|
+
if (runs.length === 0) return null;
|
|
58
|
+
const items: AutocompleteItem[] = runs.map((run) => ({
|
|
59
|
+
value: run.runId,
|
|
60
|
+
label: run.runId,
|
|
61
|
+
description: `${statusIcon(run.status)} ${run.status} · ${run.team} · ${(run.goal ?? "").slice(0, 48)}`,
|
|
62
|
+
}));
|
|
63
|
+
return filterByPrefix(items, _prefix);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Suggest task IDs within a specific run (for /team-result <runId> <taskId>). */
|
|
67
|
+
export async function suggestTaskIds(runId: string, prefix: string, cwd?: string): Promise<AutocompleteItem[] | null> {
|
|
68
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
69
|
+
// Dynamic import to avoid pulling state-store into the hot command-registration path.
|
|
70
|
+
const { loadRunManifestById } = await import("../state/state-store.ts");
|
|
71
|
+
const loaded = loadRunManifestById(resolvedCwd, runId);
|
|
72
|
+
if (!loaded) return null;
|
|
73
|
+
const items: AutocompleteItem[] = loaded.tasks.map((task) => ({
|
|
74
|
+
value: task.id,
|
|
75
|
+
label: task.id,
|
|
76
|
+
description: `${task.status} · ${task.role} · ${task.title?.slice(0, 40) ?? ""}`,
|
|
77
|
+
}));
|
|
78
|
+
return filterByPrefix(items, prefix);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Suggest available teams for /team-run <team>. */
|
|
82
|
+
export function suggestTeams(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
83
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
84
|
+
const teams = allTeams(discoverTeams(resolvedCwd));
|
|
85
|
+
if (teams.length === 0) return null;
|
|
86
|
+
const items: AutocompleteItem[] = teams.map((team) => ({
|
|
87
|
+
value: team.name,
|
|
88
|
+
label: team.name,
|
|
89
|
+
description: team.defaultWorkflow ? `workflow=${team.defaultWorkflow}` : undefined,
|
|
90
|
+
}));
|
|
91
|
+
return filterByPrefix(items, prefix);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Suggest available workflows. */
|
|
95
|
+
export function suggestWorkflows(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
96
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
97
|
+
const workflows = allWorkflows(discoverWorkflows(resolvedCwd));
|
|
98
|
+
if (workflows.length === 0) return null;
|
|
99
|
+
const items: AutocompleteItem[] = workflows.map((wf) => ({
|
|
100
|
+
value: wf.name,
|
|
101
|
+
label: wf.name,
|
|
102
|
+
description: `${wf.steps?.length ?? 0} steps`,
|
|
103
|
+
}));
|
|
104
|
+
return filterByPrefix(items, prefix);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Suggest available agents. */
|
|
108
|
+
export function suggestAgents(prefix: string, cwd?: string): AutocompleteItem[] | null {
|
|
109
|
+
const resolvedCwd = cwd ?? process.cwd();
|
|
110
|
+
const agents = allAgents(discoverAgents(resolvedCwd));
|
|
111
|
+
if (agents.length === 0) return null;
|
|
112
|
+
const items: AutocompleteItem[] = agents.map((agent) => ({
|
|
113
|
+
value: agent.name,
|
|
114
|
+
label: agent.name,
|
|
115
|
+
description: agent.description?.slice(0, 60),
|
|
116
|
+
}));
|
|
117
|
+
return filterByPrefix(items, prefix);
|
|
118
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Natural-language crew input routing (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* Pi fires the `input` event before skill/template expansion and before
|
|
5
|
+
* before_agent_start. A handler can transform the text (e.g. rewrite
|
|
6
|
+
* "crew status" → "/team-status"), or fully handle it.
|
|
7
|
+
*
|
|
8
|
+
* This module matches a small set of natural-language crew phrases and
|
|
9
|
+
* rewrites them to the equivalent slash command, so users do not need to
|
|
10
|
+
* memorize command names. Slash-command input (text starting with "/") is
|
|
11
|
+
* always passed through unchanged — we never shadow explicit commands.
|
|
12
|
+
*/
|
|
13
|
+
import type { InputEvent, InputEventResult } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
/** Rules: phrase prefix (lowercased) → slash-command rewrite. */
|
|
16
|
+
const ROUTING_RULES: ReadonlyArray<{ match: RegExp; command: string; needsArg?: boolean }> = [
|
|
17
|
+
// Inspection — no runId needed (lists all runs).
|
|
18
|
+
{ match: /^(crew|team)\s+status\b/i, command: "/team-status" },
|
|
19
|
+
{ match: /^(crew|team)\s+list\b/i, command: "/team-status" },
|
|
20
|
+
{ match: /^(crew|team)\s+(dashboard|board|panel)\b/i, command: "/team-dashboard" },
|
|
21
|
+
{ match: /^(crew|team)\s+(help|commands)\b/i, command: "/team-help" },
|
|
22
|
+
{ match: /^teams\b/i, command: "/teams" },
|
|
23
|
+
{ match: /^(crew|team)\s+(doctor|diagnos\w*)/i, command: "/team-doctor" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Try to rewrite a natural-language crew phrase into a slash command.
|
|
28
|
+
* Returns the rewritten command string, or `null` if no rule matches.
|
|
29
|
+
*
|
|
30
|
+
* Rules intentionally only match at the START of the input and require a
|
|
31
|
+
* word boundary, so ordinary sentences mentioning "crew" are untouched.
|
|
32
|
+
*/
|
|
33
|
+
export function rewriteCrewInput(text: string): string | null {
|
|
34
|
+
const trimmed = text.trim();
|
|
35
|
+
// Never transform explicit slash commands or inputs that don't start with
|
|
36
|
+
// a crew/team keyword phrase.
|
|
37
|
+
if (trimmed.startsWith("/")) return null;
|
|
38
|
+
for (const rule of ROUTING_RULES) {
|
|
39
|
+
const match = trimmed.match(rule.match);
|
|
40
|
+
if (!match) continue;
|
|
41
|
+
// Carry any remaining args after the matched phrase forward.
|
|
42
|
+
const rest = trimmed.slice(match[0].length).trim();
|
|
43
|
+
return rest ? `${rule.command} ${rest}` : rule.command;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pi `input` event handler. Transforms matching crew phrases; passes
|
|
50
|
+
* everything else through unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export function handleCrewInput(event: InputEvent): InputEventResult {
|
|
53
|
+
// Only transform interactive user input — never programmatic/scripted input.
|
|
54
|
+
if (event.source !== "interactive") return { action: "continue" };
|
|
55
|
+
const rewritten = rewriteCrewInput(event.text);
|
|
56
|
+
if (!rewritten) return { action: "continue" };
|
|
57
|
+
return { action: "transform", text: rewritten, images: event.images };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Register the crew input router on a Pi instance. Safe to call once. */
|
|
61
|
+
export function registerCrewInputRouter(pi: { on?: (event: "input", handler: (e: InputEvent) => InputEventResult) => void }): void {
|
|
62
|
+
pi.on?.("input", handleCrewInput);
|
|
63
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom message renderers for pi-crew session entries (Round 13 UX).
|
|
3
|
+
*
|
|
4
|
+
* pi-crew emits CustomMessageEntry rows via `pi.appendEntry()` for run
|
|
5
|
+
* lifecycle events (crew:run-started, crew:run-completed,
|
|
6
|
+
* crew:resume-directive). Without a registered renderer these display as
|
|
7
|
+
* raw JSON in the conversation. These renderers give them a clean,
|
|
8
|
+
* crew-branded look using the active theme.
|
|
9
|
+
*/
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { ExtensionAPI, MessageRenderOptions, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
interface CrewMessageDetails {
|
|
14
|
+
runId?: string;
|
|
15
|
+
team?: string;
|
|
16
|
+
workflow?: string;
|
|
17
|
+
agent?: string;
|
|
18
|
+
goal?: string;
|
|
19
|
+
status?: string;
|
|
20
|
+
taskCount?: number;
|
|
21
|
+
timestamp?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type MessageLike = {
|
|
25
|
+
content: string | Array<{ type: string; text?: string }>;
|
|
26
|
+
details?: CrewMessageDetails;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function extractText(message: MessageLike): string {
|
|
30
|
+
if (typeof message.content === "string") return message.content;
|
|
31
|
+
return (message.content ?? []).map((c) => c.text ?? "").join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusLevel(status: string | undefined): "success" | "error" | "warning" | "muted" {
|
|
35
|
+
switch (status) {
|
|
36
|
+
case "completed":
|
|
37
|
+
return "success";
|
|
38
|
+
case "failed":
|
|
39
|
+
case "blocked":
|
|
40
|
+
return "error";
|
|
41
|
+
case "cancelled":
|
|
42
|
+
return "warning";
|
|
43
|
+
default:
|
|
44
|
+
return "muted";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function statusIcon(status: string | undefined): string {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case "completed":
|
|
51
|
+
return "✅";
|
|
52
|
+
case "failed":
|
|
53
|
+
case "blocked":
|
|
54
|
+
return "❌";
|
|
55
|
+
case "cancelled":
|
|
56
|
+
return "🚫";
|
|
57
|
+
case "running":
|
|
58
|
+
case "planning":
|
|
59
|
+
return "🚀";
|
|
60
|
+
default:
|
|
61
|
+
return "•";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Truncate a string to maxLen chars with an ellipsis. */
|
|
66
|
+
function truncate(text: string, maxLen: number): string {
|
|
67
|
+
return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Render crew:run-started entries as a branded launch line. */
|
|
71
|
+
export function renderRunStarted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
72
|
+
const details = message.details ?? {};
|
|
73
|
+
const goal = details.goal ? truncate(details.goal, 70) : "";
|
|
74
|
+
const team = details.team ?? details.agent ?? "direct";
|
|
75
|
+
const workflow = details.workflow ?? "default";
|
|
76
|
+
const text = `🚀 crew run ${details.runId ?? ""} started — ${team}/${workflow}${goal ? ` — ${goal}` : ""}`;
|
|
77
|
+
return new Text(theme.fg("accent", theme.bold("crew ")) + theme.fg("text", text), 0, 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Render crew:run-completed entries with a status-colored summary. */
|
|
81
|
+
export function renderRunCompleted(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
82
|
+
const details = message.details ?? {};
|
|
83
|
+
const status = details.status;
|
|
84
|
+
const level = statusLevel(status);
|
|
85
|
+
const icon = statusIcon(status);
|
|
86
|
+
const goal = details.goal ? truncate(details.goal, 60) : "";
|
|
87
|
+
const tasks = details.taskCount !== undefined ? ` · ${details.taskCount} tasks` : "";
|
|
88
|
+
const text = `${icon} crew run ${details.runId ?? ""} ${status ?? "finished"}${tasks}${goal ? ` — ${goal}` : ""}`;
|
|
89
|
+
return new Text(theme.fg(level, theme.bold("crew ")) + theme.fg(level, text), 0, 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Render crew:resume-directive entries as an informational system note. */
|
|
93
|
+
export function renderResumeDirective(message: MessageLike, _options: MessageRenderOptions, theme: Theme): Text {
|
|
94
|
+
const text = extractText(message) || "Context compacted — resuming in-flight crew work.";
|
|
95
|
+
return new Text(theme.fg("muted", theme.bold("crew ") + text), 0, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Register all crew message renderers. Safe to call once at extension load. */
|
|
99
|
+
export function registerCrewMessageRenderers(
|
|
100
|
+
pi: { registerMessageRenderer?: ExtensionAPI["registerMessageRenderer"] },
|
|
101
|
+
): void {
|
|
102
|
+
// Optional chaining guards against older Pi versions (and test stubs)
|
|
103
|
+
// without registerMessageRenderer.
|
|
104
|
+
// The renderers return Text (a Component) — cast through never to match
|
|
105
|
+
// the MessageRenderer<T> signature which expects Component | undefined.
|
|
106
|
+
pi.registerMessageRenderer?.("crew:run-started", renderRunStarted as never);
|
|
107
|
+
pi.registerMessageRenderer?.("crew:run-completed", renderRunCompleted as never);
|
|
108
|
+
pi.registerMessageRenderer?.("crew:resume-directive", renderResumeDirective as never);
|
|
109
|
+
}
|
|
@@ -109,6 +109,8 @@ import {
|
|
|
109
109
|
sendFollowUp,
|
|
110
110
|
} from "./registration/subagent-helpers.ts";
|
|
111
111
|
import { registerSubagentTools } from "./registration/subagent-tools.ts";
|
|
112
|
+
import { registerCrewMessageRenderers } from "./message-renderers.ts";
|
|
113
|
+
import { registerCrewInputRouter } from "./crew-input-router.ts";
|
|
112
114
|
import { registerTeamTool } from "./registration/team-tool.ts";
|
|
113
115
|
import { handleTeamTool } from "./team-tool.ts";
|
|
114
116
|
import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
|
|
@@ -2035,4 +2037,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
2035
2037
|
}
|
|
2036
2038
|
},
|
|
2037
2039
|
});
|
|
2040
|
+
|
|
2041
|
+
// Round 13 UX: render pi-crew lifecycle entries (crew:run-started,
|
|
2042
|
+
// crew:run-completed, crew:resume-directive) with a branded look instead
|
|
2043
|
+
// of raw JSON. No-op on Pi versions without registerMessageRenderer.
|
|
2044
|
+
registerCrewMessageRenderers(pi);
|
|
2045
|
+
|
|
2046
|
+
// Round 13 UX: natural-language crew input routing. Lets users type
|
|
2047
|
+
// "crew status" instead of remembering /team-status. Only transforms
|
|
2048
|
+
// interactive input that starts with a crew/team keyword phrase; never
|
|
2049
|
+
// shadows explicit slash commands.
|
|
2050
|
+
registerCrewInputRouter(pi);
|
|
2038
2051
|
}
|
|
@@ -23,6 +23,7 @@ import { handleTeamManagerCommand } from "../team-manager-command.ts";
|
|
|
23
23
|
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
24
24
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
25
25
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
26
|
+
import { suggestRunIds, suggestTaskIds, suggestTeams } from "../command-completions.ts";
|
|
26
27
|
import * as path from "node:path";
|
|
27
28
|
// Heavy UI modules — lazy-loaded because they're only used in /crew commands.
|
|
28
29
|
// RunDashboard (288ms), DurableTextViewer (658ms), Overlays are unnecessary at Pi startup.
|
|
@@ -203,6 +204,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
203
204
|
|
|
204
205
|
pi.registerCommand("team-run", {
|
|
205
206
|
description: "Manually start a pi-crew run (agent may also use the team tool autonomously)",
|
|
207
|
+
// Round 13 UX: suggest team names for Tab-completion of the first positional arg.
|
|
208
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestTeams(argumentPrefix),
|
|
206
209
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
207
210
|
const result = await handleTeamTool(parseRunArgs(args), { ...teamCommandContext(ctx), metricRegistry: deps.getMetricRegistry?.(), startForegroundRun: (runner, runId) => deps.startForegroundRun(ctx as ExtensionContext, runner, runId), abortForegroundRun: deps.abortForegroundRun, onRunStarted: undefined });
|
|
208
211
|
await notifyCommandResult(ctx, commandText(result));
|
|
@@ -219,15 +222,21 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
219
222
|
["team-export", "export", "Export a pi-crew run bundle to artifacts/export"],
|
|
220
223
|
["team-cancel", "cancel", "Cancel a pi-crew run"],
|
|
221
224
|
] as const) {
|
|
222
|
-
pi.registerCommand(name, {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
pi.registerCommand(name, {
|
|
226
|
+
description,
|
|
227
|
+
// Round 13 UX: suggest recent run IDs for Tab-completion.
|
|
228
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
229
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
230
|
+
const runId = args.trim() || undefined;
|
|
231
|
+
const result = await handleTeamTool({ action, runId }, { ...teamCommandContext(ctx), getRunSnapshotCache: deps.getRunSnapshotCache });
|
|
232
|
+
await notifyCommandResult(ctx, commandText(result));
|
|
233
|
+
},
|
|
234
|
+
});
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
pi.registerCommand("team-invalidate", {
|
|
230
238
|
description: "Invalidate the snapshot cache for a run so the UI refreshes immediately: <runId>",
|
|
239
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
231
240
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
232
241
|
const runId = args.trim() || undefined;
|
|
233
242
|
if (!runId) {
|
|
@@ -241,6 +250,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
241
250
|
|
|
242
251
|
pi.registerCommand("team-retry", {
|
|
243
252
|
description: "Retry failed/cancelled pi-crew tasks: <runId> [taskId]",
|
|
253
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
244
254
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
245
255
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
246
256
|
const runId = tokens.shift();
|
|
@@ -256,6 +266,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
256
266
|
|
|
257
267
|
pi.registerCommand("team-respond", {
|
|
258
268
|
description: "Respond to a waiting pi-crew task: <runId> <taskId|--all> <message>",
|
|
269
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
259
270
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
260
271
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
261
272
|
const runId = tokens.shift();
|
|
@@ -269,6 +280,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
269
280
|
|
|
270
281
|
pi.registerCommand("team-follow-up", {
|
|
271
282
|
description: "Send a follow-up prompt to a pi-crew task: <runId> <taskId> <prompt>",
|
|
283
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
272
284
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
273
285
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
274
286
|
const runId = tokens.shift();
|
|
@@ -285,6 +297,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
285
297
|
|
|
286
298
|
pi.registerCommand("team-api", {
|
|
287
299
|
description: "Run safe pi-crew API interop operations: <runId> <operation> [key=value]",
|
|
300
|
+
getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix),
|
|
288
301
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
289
302
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
290
303
|
const positional = tokens.filter((token) => !token.includes("=") && !token.startsWith("--"));
|
|
@@ -329,7 +342,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
329
342
|
await notifyCommandResult(ctx, commandText(result));
|
|
330
343
|
} });
|
|
331
344
|
|
|
332
|
-
pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
345
|
+
pi.registerCommand("team-forget", { description: "Forget a pi-crew run by deleting its state and artifacts", getArgumentCompletions: (argumentPrefix: string) => suggestRunIds(argumentPrefix), handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
333
346
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
334
347
|
const runId = tokens.find((token) => !token.startsWith("--"));
|
|
335
348
|
const result = await handleTeamTool({ action: "forget", runId, force: tokens.includes("--force"), confirm: tokens.includes("--confirm") }, teamCommandContext(ctx));
|
|
@@ -415,7 +428,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
415
428
|
|
|
416
429
|
pi.registerCommand("team-cleanup", { description: "Open a simple pi-crew interactive manager", handler: handleTeamManagerCommand });
|
|
417
430
|
|
|
418
|
-
pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
431
|
+
pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
419
432
|
const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
420
433
|
const selected = await selectAgentTask(ctx, runId, rawTaskId);
|
|
421
434
|
const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
@@ -430,7 +443,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
430
443
|
await notifyCommandResult(ctx, commandText(result));
|
|
431
444
|
} });
|
|
432
445
|
|
|
433
|
-
pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
446
|
+
pi.registerCommand("team-transcript", { description: "Open a pi-crew transcript viewer: <runId> [taskId]", getArgumentCompletions: async (argumentPrefix: string) => { const parts = argumentPrefix.trim().split(/\s+/); return parts.length <= 1 ? suggestRunIds(parts[0] ?? "") : suggestTaskIds(parts[0] ?? "", parts[1] ?? ""); }, handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
434
447
|
const [runId, taskId] = args.trim().split(/\s+/).filter(Boolean);
|
|
435
448
|
if (await openTranscriptViewer(ctx, runId, taskId)) return;
|
|
436
449
|
const result = await handleTeamTool({ action: "api", runId, config: { operation: "read-agent-transcript", agentId: taskId } }, teamCommandContext(ctx));
|
|
@@ -46,6 +46,15 @@ import {
|
|
|
46
46
|
runtimeResolutionState,
|
|
47
47
|
} from "./runtime-resolver.ts";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Debug logger gated behind PI_CREW_DEBUG env var. Writes to background.log
|
|
51
|
+
* (console is redirected there). Eliminates log noise in normal operation
|
|
52
|
+
* while keeping diagnostics available when explicitly enabled.
|
|
53
|
+
*/
|
|
54
|
+
function debugLog(message: string): void {
|
|
55
|
+
if (process.env.PI_CREW_DEBUG) console.log(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
|
|
51
60
|
* can distinguish "process died" from "process still alive but quiet".
|
|
@@ -222,8 +231,7 @@ function runCleanup(
|
|
|
222
231
|
exitDueToRejection: boolean,
|
|
223
232
|
eventsPath?: string,
|
|
224
233
|
): void {
|
|
225
|
-
console.log(
|
|
226
|
-
`[background-runner] DEBUG: runCleanup, exitDueToRejection=${exitDueToRejection}`,
|
|
234
|
+
console.log(`[background-runner] runCleanup, exitDueToRejection=${exitDueToRejection}`,
|
|
227
235
|
);
|
|
228
236
|
stopInterruptGuard();
|
|
229
237
|
stopParentGuard();
|
|
@@ -492,8 +500,7 @@ async function main(): Promise<void> {
|
|
|
492
500
|
runId: manifest.runId,
|
|
493
501
|
data: { pid: process.pid },
|
|
494
502
|
});
|
|
495
|
-
|
|
496
|
-
`[background-runner] DEBUG: async.started written, pid=${process.pid}`,
|
|
503
|
+
debugLog(`[background-runner] async.started written, pid=${process.pid}`,
|
|
497
504
|
);
|
|
498
505
|
writeAsyncStartMarker(manifest, {
|
|
499
506
|
pid: process.pid,
|
|
@@ -505,7 +512,7 @@ async function main(): Promise<void> {
|
|
|
505
512
|
manifest.runId,
|
|
506
513
|
);
|
|
507
514
|
const stopInterruptGuard = startInterruptGuard(manifest, abortController, stopParentGuard);
|
|
508
|
-
|
|
515
|
+
debugLog(`[background-runner] heartbeat+interrupt guard started`);
|
|
509
516
|
// NOTE: Keep-alive interval is NOT unref'd (unlike heartbeat and interrupt
|
|
510
517
|
// guard intervals which ARE unref'd). This is intentional — during jiti
|
|
511
518
|
// compilation of team-runner.ts, the event loop must not drain prematurely.
|
|
@@ -514,25 +521,22 @@ async function main(): Promise<void> {
|
|
|
514
521
|
const keepAlive = setInterval(() => {}, 5000);
|
|
515
522
|
|
|
516
523
|
try {
|
|
517
|
-
|
|
524
|
+
debugLog(`[background-runner] about to call discoverAgents`);
|
|
518
525
|
const agents = allAgents(discoverAgents(cwd));
|
|
519
|
-
|
|
520
|
-
`[background-runner] DEBUG: discoverAgents done, ${agents.length} agents`,
|
|
526
|
+
debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`,
|
|
521
527
|
);
|
|
522
528
|
try { fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); } catch { /* best-effort */ } // FORCE flush so we see this before death
|
|
523
|
-
|
|
524
|
-
`[background-runner] DEBUG: calling directTeamAndWorkflowFromRun`,
|
|
529
|
+
debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`,
|
|
525
530
|
);
|
|
526
531
|
const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
|
|
527
|
-
|
|
532
|
+
debugLog(`[background-runner] direct done, finding team`);
|
|
528
533
|
const team =
|
|
529
534
|
direct?.team ??
|
|
530
535
|
allTeams(discoverTeams(cwd)).find(
|
|
531
536
|
(candidate) => candidate.name === manifest.team,
|
|
532
537
|
);
|
|
533
538
|
if (!team) throw new Error(`Team '${manifest.team}' not found.`);
|
|
534
|
-
|
|
535
|
-
`[background-runner] DEBUG: team=${team.name}, finding workflow`,
|
|
539
|
+
debugLog(`[background-runner] team=${team.name}, finding workflow`,
|
|
536
540
|
);
|
|
537
541
|
const baseWorkflow =
|
|
538
542
|
direct?.workflow ??
|
|
@@ -541,9 +545,9 @@ async function main(): Promise<void> {
|
|
|
541
545
|
);
|
|
542
546
|
if (!baseWorkflow)
|
|
543
547
|
throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
|
|
544
|
-
|
|
548
|
+
debugLog(`[background-runner] workflow=${baseWorkflow.name}`);
|
|
545
549
|
const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
|
|
546
|
-
|
|
550
|
+
debugLog(`[background-runner] loading config`);
|
|
547
551
|
const loadedConfig = loadConfig(cwd);
|
|
548
552
|
const runConfig =
|
|
549
553
|
manifest.runConfig &&
|
|
@@ -597,7 +601,7 @@ async function main(): Promise<void> {
|
|
|
597
601
|
// NOTE: abortController is already created above (before heartbeat/interrupt guard start)
|
|
598
602
|
// so it is available here and its signal is passed through to executeTeamRun → child-pi.
|
|
599
603
|
|
|
600
|
-
|
|
604
|
+
debugLog(`[background-runner] calling executeTeamRun`);
|
|
601
605
|
let result;
|
|
602
606
|
try {
|
|
603
607
|
result = await executeTeamRun({
|
|
@@ -615,15 +619,12 @@ async function main(): Promise<void> {
|
|
|
615
619
|
workspaceId: manifest.ownerSessionId ?? manifest.cwd,
|
|
616
620
|
signal: abortController.signal,
|
|
617
621
|
});
|
|
618
|
-
console.log(
|
|
619
|
-
`[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`,
|
|
622
|
+
console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`,
|
|
620
623
|
);
|
|
621
624
|
} catch (execError) {
|
|
622
|
-
console.log(
|
|
623
|
-
`[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
|
|
625
|
+
console.log(`[background-runner] executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
|
|
624
626
|
);
|
|
625
|
-
console.log(
|
|
626
|
-
`[background-runner] DEBUG: stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
|
|
627
|
+
console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
|
|
627
628
|
);
|
|
628
629
|
throw execError;
|
|
629
630
|
}
|
|
@@ -634,8 +635,7 @@ async function main(): Promise<void> {
|
|
|
634
635
|
runId: manifest.runId,
|
|
635
636
|
data: { status: manifest.status, tasks: tasks.length },
|
|
636
637
|
});
|
|
637
|
-
console.log(
|
|
638
|
-
`[background-runner] DEBUG: async.completed written, status=${manifest.status}`,
|
|
638
|
+
console.log(`[background-runner] async.completed written, status=${manifest.status}`,
|
|
639
639
|
);
|
|
640
640
|
if (
|
|
641
641
|
manifest.status === "failed" ||
|
|
@@ -682,8 +682,7 @@ async function main(): Promise<void> {
|
|
|
682
682
|
message,
|
|
683
683
|
});
|
|
684
684
|
process.exitCode = 1;
|
|
685
|
-
console.log(
|
|
686
|
-
`[background-runner] DEBUG: catch block, error=${error instanceof Error ? error.message : String(error)}`,
|
|
685
|
+
console.log(`[background-runner] catch block, error=${error instanceof Error ? error.message : String(error)}`,
|
|
687
686
|
);
|
|
688
687
|
} finally {
|
|
689
688
|
// FIX Issue #4: Use shared runCleanup() function for consistent cleanup
|
|
@@ -113,15 +113,20 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
|
|
|
113
113
|
const missingEvents = kept.filter((e) => e.metadata?.seq === undefined || !afterSeqs.has(e.metadata.seq));
|
|
114
114
|
let recoveredCount = 0;
|
|
115
115
|
let recoveryFailed = false;
|
|
116
|
-
|
|
116
|
+
if (missingEvents.length > 0) {
|
|
117
|
+
// BUGFIX (Round 12 C2): the previous loop called atomicWriteFile PER event,
|
|
118
|
+
// which REPLACES the entire file each iteration — destroying the
|
|
119
|
+
// compacted log and all previously-recovered events, leaving only the
|
|
120
|
+
// LAST missing event. FIX: accumulate all missing events into one
|
|
121
|
+
// string and append in a single write (appendFileSync appends without
|
|
122
|
+
// destroying existing content).
|
|
123
|
+
const recoveryLines = missingEvents.map((e) => JSON.stringify(e) + "\n").join("");
|
|
117
124
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
recoveredCount++;
|
|
125
|
+
fs.appendFileSync(eventsPath, recoveryLines);
|
|
126
|
+
recoveredCount = missingEvents.length;
|
|
121
127
|
} catch (err) {
|
|
122
128
|
recoveryFailed = true;
|
|
123
|
-
|
|
124
|
-
logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvent=${JSON.stringify(event).slice(0, 100)}`);
|
|
129
|
+
logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvents=${missingEvents.length}`);
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
return {
|
|
@@ -159,12 +164,24 @@ export function rotateEventLog(eventsPath: string): boolean {
|
|
|
159
164
|
return withEventLogLockSync(eventsPath, () => {
|
|
160
165
|
try {
|
|
161
166
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
fs.
|
|
167
|
+
let archivePath = `${eventsPath}.${ts}.archive.jsonl`;
|
|
168
|
+
// Round 12: avoid timestamp collisions when two rotations happen within
|
|
169
|
+
// the same millisecond (copyFileSync would silently overwrite the
|
|
170
|
+
// first archive). Append a counter until the path is free.
|
|
171
|
+
let collision = 1;
|
|
172
|
+
while (fs.existsSync(archivePath)) {
|
|
173
|
+
archivePath = `${eventsPath}.${ts}.${collision}.archive.jsonl`;
|
|
174
|
+
collision++;
|
|
175
|
+
}
|
|
176
|
+
// BUGFIX (Round 12 C1): the previous order (atomicWriteFile empty THEN
|
|
177
|
+
// rename) destroyed ALL events — atomicWriteFile replaces the file
|
|
178
|
+
// in place, so the rename then moved an EMPTY file to the archive.
|
|
179
|
+
// FIX: copy current content to the archive first (archive is populated,
|
|
180
|
+
// original still intact), then truncate the original to empty in place.
|
|
181
|
+
// copyFileSync + writeFileSync("") ensures eventsPath ALWAYS exists
|
|
182
|
+
// (no missing-file window for concurrent readers).
|
|
183
|
+
fs.copyFileSync(eventsPath, archivePath);
|
|
184
|
+
fs.writeFileSync(eventsPath, "", "utf-8");
|
|
168
185
|
return true;
|
|
169
186
|
} catch (error) {
|
|
170
187
|
logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
|
package/src/state/mailbox.ts
CHANGED
|
@@ -408,10 +408,16 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Omit<Ma
|
|
|
408
408
|
// 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat
|
|
409
409
|
// check; rotates at most once per append.
|
|
410
410
|
rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
delivery.
|
|
414
|
-
|
|
411
|
+
// BUGFIX (Round 12 C3): the delivery.json read-modify-write below was
|
|
412
|
+
// UNLOCKED, so concurrent appendMailboxMessage calls could interleave and
|
|
413
|
+
// clobber each other's delivery entries (lost-update race). FIX: wrap the
|
|
414
|
+
// entire read-modify-write in a file lock on the delivery file.
|
|
415
|
+
withFileLockSync(deliveryFile(manifest, true), () => {
|
|
416
|
+
const delivery = readDeliveryState(manifest);
|
|
417
|
+
delivery.messages[complete.id] = complete.status;
|
|
418
|
+
delivery.updatedAt = createdAt;
|
|
419
|
+
writeDeliveryState(manifest, delivery);
|
|
420
|
+
});
|
|
415
421
|
return complete;
|
|
416
422
|
}
|
|
417
423
|
|
|
@@ -437,12 +443,16 @@ export function readMailboxMessage(manifest: TeamRunManifest, messageId: string)
|
|
|
437
443
|
}
|
|
438
444
|
|
|
439
445
|
export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
+
// BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
|
|
447
|
+
// clobber concurrent appends. FIX: wrap in a file lock.
|
|
448
|
+
return withFileLockSync(deliveryFile(manifest, true), () => {
|
|
449
|
+
const delivery = readDeliveryState(manifest);
|
|
450
|
+
if (!delivery.messages[messageId]) throw new Error(`Mailbox message '${messageId}' not found.`);
|
|
451
|
+
delivery.messages[messageId] = "acknowledged";
|
|
452
|
+
delivery.updatedAt = new Date().toISOString();
|
|
453
|
+
writeDeliveryState(manifest, delivery);
|
|
454
|
+
return delivery;
|
|
455
|
+
});
|
|
446
456
|
}
|
|
447
457
|
|
|
448
458
|
/**
|
|
@@ -503,14 +513,18 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
|
|
|
503
513
|
}
|
|
504
514
|
|
|
505
515
|
export function replayPendingMailboxMessages(manifest: TeamRunManifest): MailboxReplayResult {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
516
|
+
// BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
|
|
517
|
+
// clobber concurrent appends/acknowledgments. FIX: wrap in a file lock.
|
|
518
|
+
return withFileLockSync(deliveryFile(manifest, true), () => {
|
|
519
|
+
const delivery = readDeliveryState(manifest);
|
|
520
|
+
const pending = readAllInboxMessages(manifest).filter((message) => message.status !== "acknowledged" && delivery.messages[message.id] !== "acknowledged");
|
|
521
|
+
if (!pending.length) return { messages: [], updatedAt: delivery.updatedAt };
|
|
522
|
+
const updatedAt = new Date().toISOString();
|
|
523
|
+
for (const message of pending) delivery.messages[message.id] = "delivered";
|
|
524
|
+
delivery.updatedAt = updatedAt;
|
|
525
|
+
writeDeliveryState(manifest, delivery);
|
|
526
|
+
return { messages: pending, updatedAt };
|
|
527
|
+
});
|
|
514
528
|
}
|
|
515
529
|
|
|
516
530
|
export function validateMailbox(manifest: TeamRunManifest, options: { repair?: boolean; signal?: AbortSignal } = {}): MailboxValidationReport {
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* Actual capture hooks into the lifecycle events (Pattern 12).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
15
|
+
import { atomicWriteJson } from "./atomic-write.ts";
|
|
15
16
|
|
|
16
17
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -153,12 +154,13 @@ export class ObservationStore {
|
|
|
153
154
|
*/
|
|
154
155
|
save(): void {
|
|
155
156
|
try {
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// BUGFIX (Round 12 I4): use atomicWriteJson (temp-file + rename) instead
|
|
158
|
+
// of raw writeFileSync, so a crash mid-write cannot leave a truncated /
|
|
159
|
+
// empty file that breaks load() on restart.
|
|
160
|
+
atomicWriteJson(this.storePath, {
|
|
159
161
|
observations: this.observations,
|
|
160
162
|
compressed: this.compressed,
|
|
161
|
-
}
|
|
163
|
+
});
|
|
162
164
|
} catch (error) {
|
|
163
165
|
logInternalError("observation-store.save", error, `path=${this.storePath}`);
|
|
164
166
|
}
|