@os-eco/overstory-cli 0.9.4 → 0.10.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/README.md +47 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +203 -5
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +73 -1
- package/src/commands/sling.ts +149 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +3 -0
- package/src/worktree/tmux.ts +10 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/commands/log.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { Command } from "commander";
|
|
15
|
+
import { isStopHookPersistentCapability } from "../agents/capabilities.ts";
|
|
15
16
|
import { updateIdentity } from "../agents/identity.ts";
|
|
16
17
|
import { loadConfig } from "../config.ts";
|
|
17
18
|
import { ValidationError } from "../errors.ts";
|
|
@@ -66,8 +67,12 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
|
|
|
66
67
|
const session = store.getByName(agentName);
|
|
67
68
|
if (session) {
|
|
68
69
|
store.updateLastActivity(agentName);
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// Tool-use observed: try booting → working. Matrix-guarded so a
|
|
71
|
+
// zombie classification (set by watchdog) is NOT silently revived
|
|
72
|
+
// here — that revival was a contributor to the schizophrenic
|
|
73
|
+
// state=zombie + tool-use-active symptom in overstory-a993.
|
|
74
|
+
if (session.state === "booting") {
|
|
75
|
+
store.tryTransitionState(agentName, "working");
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
} finally {
|
|
@@ -79,63 +84,144 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
|
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
* Maximum retry attempts for the session-end transition.
|
|
88
|
+
*
|
|
89
|
+
* The Stop hook is the only signal that turns sessions.db state from
|
|
90
|
+
* "working" to "completed" for headless legacy paths and tmux sessions.
|
|
91
|
+
* If it loses that signal due to a transient SQLite contention error
|
|
92
|
+
* (e.g. "database is locked" while the watchdog ticks against the same
|
|
93
|
+
* file), the row stays in "working" forever and the watchdog later
|
|
94
|
+
* promotes it to "zombie". Retrying with exponential backoff lets brief
|
|
95
|
+
* lock contention resolve before we give up. (overstory-e74b)
|
|
85
96
|
*/
|
|
86
|
-
const
|
|
97
|
+
const TRANSITION_MAX_ATTEMPTS = 5;
|
|
98
|
+
const TRANSITION_BACKOFF_BASE_MS = 50;
|
|
87
99
|
|
|
88
100
|
/**
|
|
89
|
-
*
|
|
90
|
-
* Called when session-end event fires.
|
|
91
|
-
*
|
|
92
|
-
* Skips the transition for persistent agent types (coordinator, orchestrator, monitor)
|
|
93
|
-
* whose Stop hook fires every turn, not just at true session end.
|
|
101
|
+
* One attempt at the session-end state transition.
|
|
94
102
|
*
|
|
95
|
-
*
|
|
103
|
+
* Throws on transient failures (e.g. SQLite "database is locked") so the
|
|
104
|
+
* caller can retry. The body is the original logic from
|
|
105
|
+
* `transitionToCompleted`.
|
|
96
106
|
*/
|
|
97
|
-
function
|
|
107
|
+
function transitionToCompletedOnce(projectRoot: string, agentName: string): void {
|
|
108
|
+
const overstoryDir = join(projectRoot, ".overstory");
|
|
109
|
+
const { store } = openSessionStore(overstoryDir);
|
|
98
110
|
try {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
store.updateState(agentName, "completed");
|
|
118
|
-
store.updateLastActivity(agentName);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
} finally {
|
|
122
|
-
runStore.close();
|
|
111
|
+
const session = store.getByName(agentName);
|
|
112
|
+
if (session && isStopHookPersistentCapability(session.capability)) {
|
|
113
|
+
// Check if a persistent top-level agent self-exited by verifying the run
|
|
114
|
+
// is already completed.
|
|
115
|
+
// If `ov run complete` was called before session-end, the run status is 'completed'
|
|
116
|
+
// and we should transition the persistent session to completed too.
|
|
117
|
+
if (
|
|
118
|
+
(session.capability === "coordinator" || session.capability === "orchestrator") &&
|
|
119
|
+
session.runId
|
|
120
|
+
) {
|
|
121
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
122
|
+
try {
|
|
123
|
+
const run = runStore.getRun(session.runId);
|
|
124
|
+
if (run && run.status === "completed") {
|
|
125
|
+
// Self-exit: the persistent agent called ov run complete before session ended
|
|
126
|
+
store.updateState(agentName, "completed");
|
|
127
|
+
store.updateLastActivity(agentName);
|
|
128
|
+
return;
|
|
123
129
|
}
|
|
130
|
+
} finally {
|
|
131
|
+
runStore.close();
|
|
124
132
|
}
|
|
125
|
-
// Normal persistent agent: only update activity, don't mark completed
|
|
126
|
-
store.updateLastActivity(agentName);
|
|
127
|
-
return;
|
|
128
133
|
}
|
|
129
|
-
|
|
134
|
+
// Normal persistent agent: only update activity, don't mark completed
|
|
130
135
|
store.updateLastActivity(agentName);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
store.updateState(agentName, "completed");
|
|
139
|
+
store.updateLastActivity(agentName);
|
|
140
|
+
} finally {
|
|
141
|
+
store.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Best-effort: log a session-end hook failure to events.db so it surfaces in
|
|
147
|
+
* `ov errors` and trace timelines. Swallows secondary errors (events.db may
|
|
148
|
+
* also be locked when the primary write failed).
|
|
149
|
+
*/
|
|
150
|
+
async function logHookFailure(
|
|
151
|
+
projectRoot: string,
|
|
152
|
+
agentName: string,
|
|
153
|
+
hookName: string,
|
|
154
|
+
error: unknown,
|
|
155
|
+
attempts: number,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
try {
|
|
158
|
+
const eventsDbPath = join(projectRoot, ".overstory", "events.db");
|
|
159
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
160
|
+
try {
|
|
161
|
+
eventStore.insert({
|
|
162
|
+
runId: null,
|
|
163
|
+
agentName,
|
|
164
|
+
sessionId: null,
|
|
165
|
+
eventType: "error",
|
|
166
|
+
toolName: null,
|
|
167
|
+
toolArgs: null,
|
|
168
|
+
toolDurationMs: null,
|
|
169
|
+
level: "error",
|
|
170
|
+
data: JSON.stringify({
|
|
171
|
+
hook: hookName,
|
|
172
|
+
attempts,
|
|
173
|
+
message: error instanceof Error ? error.message : String(error),
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
131
176
|
} finally {
|
|
132
|
-
|
|
177
|
+
eventStore.close();
|
|
133
178
|
}
|
|
134
179
|
} catch {
|
|
135
|
-
// Non-fatal:
|
|
180
|
+
// Non-fatal: events.db may also be unavailable when the primary write failed.
|
|
136
181
|
}
|
|
137
182
|
}
|
|
138
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Transition agent state to 'completed' in the SessionStore.
|
|
186
|
+
* Called when session-end event fires.
|
|
187
|
+
*
|
|
188
|
+
* Retries on transient SQLite contention with exponential backoff
|
|
189
|
+
* (50/100/200/400/800ms). On persistent failure, records an `error` event
|
|
190
|
+
* to events.db so the missed signal shows up in observability tooling and
|
|
191
|
+
* the watchdog's stale-but-tmux-dead fallback can recognize it.
|
|
192
|
+
* (overstory-e74b)
|
|
193
|
+
*
|
|
194
|
+
* Skips the transition for capabilities in `STOP_HOOK_PERSISTENT_CAPABILITIES`
|
|
195
|
+
* (coordinator, orchestrator, monitor, lead) whose Stop hook fires every model
|
|
196
|
+
* turn rather than once at true session end. See
|
|
197
|
+
* `src/agents/capabilities.ts` for the full rationale and consumer list.
|
|
198
|
+
*
|
|
199
|
+
* Non-fatal: silently ignores errors to avoid breaking hook execution.
|
|
200
|
+
*/
|
|
201
|
+
async function transitionToCompleted(projectRoot: string, agentName: string): Promise<void> {
|
|
202
|
+
let lastError: unknown;
|
|
203
|
+
for (let attempt = 0; attempt < TRANSITION_MAX_ATTEMPTS; attempt++) {
|
|
204
|
+
try {
|
|
205
|
+
transitionToCompletedOnce(projectRoot, agentName);
|
|
206
|
+
return;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
lastError = err;
|
|
209
|
+
if (attempt < TRANSITION_MAX_ATTEMPTS - 1) {
|
|
210
|
+
await Bun.sleep(TRANSITION_BACKOFF_BASE_MS * 2 ** attempt);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// All retries failed — surface the missed signal via events.db.
|
|
216
|
+
await logHookFailure(
|
|
217
|
+
projectRoot,
|
|
218
|
+
agentName,
|
|
219
|
+
"session-end:transitionToCompleted",
|
|
220
|
+
lastError,
|
|
221
|
+
TRANSITION_MAX_ATTEMPTS,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
139
225
|
/**
|
|
140
226
|
* Look up an agent's session record.
|
|
141
227
|
* Returns null if not found.
|
|
@@ -629,8 +715,9 @@ async function runLog(opts: {
|
|
|
629
715
|
}
|
|
630
716
|
case "session-end":
|
|
631
717
|
logger.info("session.end", { agentName: opts.agent });
|
|
632
|
-
// Transition agent state to completed
|
|
633
|
-
|
|
718
|
+
// Transition agent state to completed (with retry/backoff and
|
|
719
|
+
// events.db fallback on persistent failure — overstory-e74b).
|
|
720
|
+
await transitionToCompleted(config.project.root, opts.agent);
|
|
634
721
|
// Look up agent session for identity update and metrics recording
|
|
635
722
|
{
|
|
636
723
|
const agentSession = getAgentSession(config.project.root, opts.agent);
|
|
@@ -647,28 +734,6 @@ async function runLog(opts: {
|
|
|
647
734
|
// Non-fatal: identity may not exist for this agent
|
|
648
735
|
}
|
|
649
736
|
|
|
650
|
-
// Auto-nudge coordinator when a lead completes so it wakes up
|
|
651
|
-
// to process merge_ready / worker_done messages without waiting
|
|
652
|
-
// for user input (see decision mx-728f8d).
|
|
653
|
-
if (agentSession?.capability === "lead") {
|
|
654
|
-
try {
|
|
655
|
-
const nudgesDir = join(config.project.root, ".overstory", "pending-nudges");
|
|
656
|
-
const { mkdir } = await import("node:fs/promises");
|
|
657
|
-
await mkdir(nudgesDir, { recursive: true });
|
|
658
|
-
const markerPath = join(nudgesDir, "coordinator.json");
|
|
659
|
-
const marker = {
|
|
660
|
-
from: opts.agent,
|
|
661
|
-
reason: "lead_completed",
|
|
662
|
-
subject: `Lead ${opts.agent} completed — check mail for merge_ready/worker_done`,
|
|
663
|
-
messageId: `auto-nudge-${opts.agent}-${Date.now()}`,
|
|
664
|
-
createdAt: new Date().toISOString(),
|
|
665
|
-
};
|
|
666
|
-
await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
|
|
667
|
-
} catch {
|
|
668
|
-
// Non-fatal: nudge failure should not break session-end
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
737
|
// Record session metrics (with optional token data from transcript)
|
|
673
738
|
if (agentSession) {
|
|
674
739
|
// NOTE: We intentionally do NOT auto-complete the run here for coordinator agents.
|
|
@@ -730,7 +795,7 @@ async function runLog(opts: {
|
|
|
730
795
|
|
|
731
796
|
// Auto-record expertise via mulch learn + record (post-session).
|
|
732
797
|
// Skip persistent agents whose Stop hook fires every turn.
|
|
733
|
-
if (!
|
|
798
|
+
if (!isStopHookPersistentCapability(agentSession.capability)) {
|
|
734
799
|
try {
|
|
735
800
|
const mulchClient = createMulchClient(config.project.root);
|
|
736
801
|
const mailDbPath = join(config.project.root, ".overstory", "mail.db");
|
|
@@ -751,7 +816,7 @@ async function runLog(opts: {
|
|
|
751
816
|
|
|
752
817
|
// Append outcomes to applied mulch records (outcome feedback loop).
|
|
753
818
|
// Reads applied-records.json written by sling.ts at spawn time.
|
|
754
|
-
if (!
|
|
819
|
+
if (!isStopHookPersistentCapability(agentSession.capability)) {
|
|
755
820
|
try {
|
|
756
821
|
const mulchClient = createMulchClient(config.project.root);
|
|
757
822
|
await appendOutcomeToAppliedRecords({
|
|
@@ -118,6 +118,54 @@ describe("mailCommand", () => {
|
|
|
118
118
|
expect(output).toContain("Explore API");
|
|
119
119
|
expect(output).toContain("Total: 2 messages");
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
test("--type filters by message type", async () => {
|
|
123
|
+
// Add a typed message to the seeded inbox
|
|
124
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
125
|
+
const client = createMailClient(store);
|
|
126
|
+
client.send({
|
|
127
|
+
from: "lead-x",
|
|
128
|
+
to: "coordinator",
|
|
129
|
+
subject: "merge_ready: t1",
|
|
130
|
+
body: "ready to merge",
|
|
131
|
+
type: "merge_ready",
|
|
132
|
+
});
|
|
133
|
+
client.close();
|
|
134
|
+
|
|
135
|
+
await mailCommand(["list", "--type", "merge_ready"]);
|
|
136
|
+
expect(output).toContain("merge_ready: t1");
|
|
137
|
+
expect(output).not.toContain("Build task");
|
|
138
|
+
expect(output).not.toContain("Explore API");
|
|
139
|
+
expect(output).toContain("Total: 1 message");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("--type combined with --from filters by both", async () => {
|
|
143
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
144
|
+
const client = createMailClient(store);
|
|
145
|
+
client.send({
|
|
146
|
+
from: "lead-x",
|
|
147
|
+
to: "coordinator",
|
|
148
|
+
subject: "merge_ready: t1",
|
|
149
|
+
body: "ready",
|
|
150
|
+
type: "merge_ready",
|
|
151
|
+
});
|
|
152
|
+
client.send({
|
|
153
|
+
from: "lead-y",
|
|
154
|
+
to: "coordinator",
|
|
155
|
+
subject: "merge_ready: t2",
|
|
156
|
+
body: "ready",
|
|
157
|
+
type: "merge_ready",
|
|
158
|
+
});
|
|
159
|
+
client.close();
|
|
160
|
+
|
|
161
|
+
await mailCommand(["list", "--from", "lead-x", "--type", "merge_ready"]);
|
|
162
|
+
expect(output).toContain("merge_ready: t1");
|
|
163
|
+
expect(output).not.toContain("merge_ready: t2");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("--type rejects invalid type with ValidationError", async () => {
|
|
167
|
+
await expect(mailCommand(["list", "--type", "bogus"])).rejects.toThrow(/Invalid --type/);
|
|
168
|
+
});
|
|
121
169
|
});
|
|
122
170
|
|
|
123
171
|
describe("reply", () => {
|
|
@@ -1274,6 +1322,120 @@ describe("mailCommand", () => {
|
|
|
1274
1322
|
expect(stderrOutput).toBe("");
|
|
1275
1323
|
});
|
|
1276
1324
|
});
|
|
1325
|
+
|
|
1326
|
+
describe("terminal-state recipient rejection (overstory-f5be)", () => {
|
|
1327
|
+
async function seedRecipient(name: string, state: "working" | "completed" | "zombie") {
|
|
1328
|
+
const { createSessionStore } = await import("../sessions/store.ts");
|
|
1329
|
+
const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
|
|
1330
|
+
const sessionStore = createSessionStore(sessionsDbPath);
|
|
1331
|
+
sessionStore.upsert({
|
|
1332
|
+
id: `session-${name}`,
|
|
1333
|
+
agentName: name,
|
|
1334
|
+
capability: "builder",
|
|
1335
|
+
worktreePath: `/worktrees/${name}`,
|
|
1336
|
+
branchName: name,
|
|
1337
|
+
taskId: "bead-x",
|
|
1338
|
+
tmuxSession: `overstory-test-${name}`,
|
|
1339
|
+
state,
|
|
1340
|
+
pid: 99999,
|
|
1341
|
+
parentAgent: "orchestrator",
|
|
1342
|
+
depth: 1,
|
|
1343
|
+
runId: "run-001",
|
|
1344
|
+
startedAt: new Date().toISOString(),
|
|
1345
|
+
lastActivity: new Date().toISOString(),
|
|
1346
|
+
escalationLevel: 0,
|
|
1347
|
+
stalledSince: null,
|
|
1348
|
+
transcriptPath: null,
|
|
1349
|
+
});
|
|
1350
|
+
sessionStore.close();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
test("rejects send to recipient in completed state", async () => {
|
|
1354
|
+
await seedRecipient("dead-builder", "completed");
|
|
1355
|
+
|
|
1356
|
+
let caught: unknown;
|
|
1357
|
+
try {
|
|
1358
|
+
await mailCommand([
|
|
1359
|
+
"send",
|
|
1360
|
+
"--to",
|
|
1361
|
+
"dead-builder",
|
|
1362
|
+
"--subject",
|
|
1363
|
+
"Hello",
|
|
1364
|
+
"--body",
|
|
1365
|
+
"Are you there?",
|
|
1366
|
+
]);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
caught = err;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
expect(caught).toBeDefined();
|
|
1372
|
+
expect((caught as Error).name).toBe("MailError");
|
|
1373
|
+
expect((caught as Error).message).toContain("dead-builder");
|
|
1374
|
+
expect((caught as Error).message).toContain("completed");
|
|
1375
|
+
|
|
1376
|
+
// Confirm no message was inserted
|
|
1377
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1378
|
+
const client = createMailClient(store);
|
|
1379
|
+
const messages = client.list({ to: "dead-builder" });
|
|
1380
|
+
expect(messages.length).toBe(0);
|
|
1381
|
+
client.close();
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
test("rejects send to recipient in zombie state", async () => {
|
|
1385
|
+
await seedRecipient("crashed-builder", "zombie");
|
|
1386
|
+
|
|
1387
|
+
let caught: unknown;
|
|
1388
|
+
try {
|
|
1389
|
+
await mailCommand([
|
|
1390
|
+
"send",
|
|
1391
|
+
"--to",
|
|
1392
|
+
"crashed-builder",
|
|
1393
|
+
"--subject",
|
|
1394
|
+
"Status?",
|
|
1395
|
+
"--body",
|
|
1396
|
+
"Ping",
|
|
1397
|
+
]);
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
caught = err;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
expect(caught).toBeDefined();
|
|
1403
|
+
expect((caught as Error).name).toBe("MailError");
|
|
1404
|
+
expect((caught as Error).message).toContain("zombie");
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("allows send when recipient has no session row (e.g. orchestrator)", async () => {
|
|
1408
|
+
// No session seeded for "orchestrator" — the existing beforeEach
|
|
1409
|
+
// only inserts mail rows, not session rows.
|
|
1410
|
+
await mailCommand([
|
|
1411
|
+
"send",
|
|
1412
|
+
"--to",
|
|
1413
|
+
"orchestrator",
|
|
1414
|
+
"--subject",
|
|
1415
|
+
"Hello",
|
|
1416
|
+
"--body",
|
|
1417
|
+
"Top-level role",
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1421
|
+
const client = createMailClient(store);
|
|
1422
|
+
const messages = client.list({ to: "orchestrator" });
|
|
1423
|
+
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
1424
|
+
client.close();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("allows send to active (working) recipient", async () => {
|
|
1428
|
+
await seedRecipient("live-builder", "working");
|
|
1429
|
+
|
|
1430
|
+
await mailCommand(["send", "--to", "live-builder", "--subject", "Hello", "--body", "Active"]);
|
|
1431
|
+
|
|
1432
|
+
const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
|
|
1433
|
+
const client = createMailClient(store);
|
|
1434
|
+
const messages = client.list({ to: "live-builder" });
|
|
1435
|
+
expect(messages.length).toBe(1);
|
|
1436
|
+
client.close();
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1277
1439
|
});
|
|
1278
1440
|
|
|
1279
1441
|
describe("shouldAutoNudge", () => {
|
package/src/commands/mail.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { Command } from "commander";
|
|
10
|
+
import { Command, CommanderError } from "commander";
|
|
11
11
|
import { resolveProjectRoot } from "../config.ts";
|
|
12
|
-
import { ValidationError } from "../errors.ts";
|
|
12
|
+
import { MailError, ValidationError } from "../errors.ts";
|
|
13
13
|
import { createEventStore } from "../events/store.ts";
|
|
14
14
|
import { jsonOutput } from "../json.ts";
|
|
15
15
|
import { accent, printHint, printSuccess } from "../logging/color.ts";
|
|
@@ -253,6 +253,7 @@ interface ListOpts {
|
|
|
253
253
|
to?: string;
|
|
254
254
|
agent?: string;
|
|
255
255
|
unread?: boolean;
|
|
256
|
+
type?: string;
|
|
256
257
|
json?: boolean;
|
|
257
258
|
}
|
|
258
259
|
|
|
@@ -405,6 +406,30 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
|
|
|
405
406
|
}
|
|
406
407
|
}
|
|
407
408
|
|
|
409
|
+
// Reject sends to agents in a terminal state (completed/zombie).
|
|
410
|
+
// `installMailInjectors` reaps the per-agent dispatch loop the moment a
|
|
411
|
+
// session lands in a terminal state (serve.ts:378), so any mail addressed
|
|
412
|
+
// after that point would sit unread forever with no way to surface it.
|
|
413
|
+
// Sessions with no row at all (orchestrator, coordinator, operator roles)
|
|
414
|
+
// fall through — we only know about agents tracked in SessionStore.
|
|
415
|
+
// Group addresses already skip terminal agents via `getActive()`.
|
|
416
|
+
{
|
|
417
|
+
const overstoryDir = join(cwd, ".overstory");
|
|
418
|
+
const { store: sessionStore } = openSessionStore(overstoryDir);
|
|
419
|
+
try {
|
|
420
|
+
const recipient = sessionStore.getByName(to);
|
|
421
|
+
if (recipient && (recipient.state === "completed" || recipient.state === "zombie")) {
|
|
422
|
+
throw new MailError(
|
|
423
|
+
`Recipient "${to}" is in terminal state (${recipient.state}); message not sent. ` +
|
|
424
|
+
`The agent is no longer running, so this message would never be delivered.`,
|
|
425
|
+
{ agentName: to },
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
} finally {
|
|
429
|
+
sessionStore.close();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
// Single-recipient message (existing logic)
|
|
409
434
|
const client = openClient(cwd);
|
|
410
435
|
try {
|
|
@@ -603,9 +628,20 @@ function handleList(opts: ListOpts, cwd: string): void {
|
|
|
603
628
|
const unread = opts.unread ? true : undefined;
|
|
604
629
|
const json = opts.json ?? false;
|
|
605
630
|
|
|
631
|
+
let type: MailMessageType | undefined;
|
|
632
|
+
if (opts.type !== undefined) {
|
|
633
|
+
if (!MAIL_MESSAGE_TYPES.includes(opts.type as MailMessageType)) {
|
|
634
|
+
throw new ValidationError(
|
|
635
|
+
`Invalid --type "${opts.type}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
|
|
636
|
+
{ field: "type", value: opts.type },
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
type = opts.type as MailMessageType;
|
|
640
|
+
}
|
|
641
|
+
|
|
606
642
|
const client = openClient(cwd);
|
|
607
643
|
try {
|
|
608
|
-
const messages = client.list({ from, to, unread });
|
|
644
|
+
const messages = client.list({ from, to, unread, type });
|
|
609
645
|
|
|
610
646
|
if (json) {
|
|
611
647
|
jsonOutput("mail list", { messages });
|
|
@@ -732,8 +768,8 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
732
768
|
|
|
733
769
|
program
|
|
734
770
|
.command("check")
|
|
735
|
-
.description("Check inbox
|
|
736
|
-
.option("--agent <name>", "Agent name")
|
|
771
|
+
.description("Check inbox for one agent and mark unread as read (per-agent scope)")
|
|
772
|
+
.option("--agent <name>", "Agent name (default: orchestrator)")
|
|
737
773
|
.option("--inject", "Inject format for hook context")
|
|
738
774
|
.option("--json", "Output as JSON")
|
|
739
775
|
.option("--debounce <ms>", "Debounce interval in milliseconds")
|
|
@@ -744,11 +780,12 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
744
780
|
|
|
745
781
|
program
|
|
746
782
|
.command("list")
|
|
747
|
-
.description("List messages with filters")
|
|
783
|
+
.description("List messages with filters (system-wide unless --to/--agent given)")
|
|
748
784
|
.option("--from <name>", "Filter by sender")
|
|
749
|
-
.option("--to <name>", "Filter by recipient")
|
|
785
|
+
.option("--to <name>", "Filter by recipient (scopes to one agent)")
|
|
750
786
|
.option("--agent <name>", "Alias for --to (filter by recipient)")
|
|
751
|
-
.option("--unread", "Show only unread messages")
|
|
787
|
+
.option("--unread", "Show only unread messages (does NOT mark them read)")
|
|
788
|
+
.option("--type <type>", "Filter by message type")
|
|
752
789
|
.option("--json", "Output as JSON")
|
|
753
790
|
.exitOverride()
|
|
754
791
|
.action((opts: ListOpts) => {
|
|
@@ -789,5 +826,23 @@ export async function mailCommand(args: string[]): Promise<void> {
|
|
|
789
826
|
handlePurge(opts, root);
|
|
790
827
|
});
|
|
791
828
|
|
|
792
|
-
|
|
829
|
+
try {
|
|
830
|
+
await program.parseAsync(["node", "overstory-mail", ...args]);
|
|
831
|
+
} catch (err) {
|
|
832
|
+
// `exitOverride()` turns Commander's help paths into thrown
|
|
833
|
+
// CommanderErrors after the help text was already written to stdout.
|
|
834
|
+
// Swallow both the explicit `--help` path (commander.helpDisplayed,
|
|
835
|
+
// exitCode 0) and the missing-subcommand path (commander.help,
|
|
836
|
+
// exitCode 1) — the user got what they asked for.
|
|
837
|
+
if (
|
|
838
|
+
err instanceof CommanderError &&
|
|
839
|
+
(err.code === "commander.helpDisplayed" || err.code === "commander.help")
|
|
840
|
+
) {
|
|
841
|
+
if (err.exitCode !== 0) {
|
|
842
|
+
process.exitCode = err.exitCode;
|
|
843
|
+
}
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
throw err;
|
|
847
|
+
}
|
|
793
848
|
}
|