@afokapu/atdd-flow 0.3.7 → 0.3.8
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 +50 -33
- package/extensions/pi/index.ts +248 -77
- package/multiplexer/herdr.yaml +17 -0
- package/package.json +3 -2
- package/src/adapters.ts +10 -31
- package/src/cli.ts +8 -7
- package/src/core.ts +34 -2
- package/src/multiplexer.ts +204 -0
- package/src/overview.ts +3 -0
- package/src/runtime-state.ts +182 -0
- package/src/seats.ts +21 -53
- package/src/threads.ts +137 -12
package/README.md
CHANGED
|
@@ -50,12 +50,9 @@ an absolute command path when the host does not guarantee a shared `PATH`:
|
|
|
50
50
|
```yaml
|
|
51
51
|
schema: atdd-workflow/desk/v1
|
|
52
52
|
desk: desk
|
|
53
|
-
application:
|
|
53
|
+
application: herdr
|
|
54
54
|
executables:
|
|
55
|
-
claude: /Users/you/.local/bin/claude
|
|
56
|
-
codex: /opt/homebrew/bin/codex
|
|
57
55
|
pi: /opt/homebrew/bin/pi
|
|
58
|
-
kimi: /Users/you/.kimi-code/bin/kimi
|
|
59
56
|
```
|
|
60
57
|
|
|
61
58
|
The executable registry is transport configuration, not model allocation. New seats do not pin an
|
|
@@ -64,25 +61,18 @@ agent. Instead, `models.yaml` declares the launchable model portfolio in descend
|
|
|
64
61
|
```yaml
|
|
65
62
|
schema: atdd-workflow/models/v1
|
|
66
63
|
models:
|
|
67
|
-
- id:
|
|
68
|
-
executable:
|
|
69
|
-
|
|
70
|
-
- id: standard
|
|
71
|
-
executable: claude
|
|
72
|
-
args: [--model, sonnet]
|
|
73
|
-
- id: codex
|
|
74
|
-
executable: codex
|
|
75
|
-
- id: glm
|
|
76
|
-
executable: glm
|
|
77
|
-
enabled: false
|
|
64
|
+
- id: pi
|
|
65
|
+
executable: pi
|
|
66
|
+
# Add Pi --model arguments here when local policy selects a specific Pi model.
|
|
78
67
|
```
|
|
79
68
|
|
|
80
|
-
Order is policy: strongest first, weakest last.
|
|
69
|
+
Order is policy: strongest first, weakest last. New Desks use Pi for every entry; add Pi `--model`
|
|
70
|
+
arguments when local policy selects a specific model. Entries whose executable is unavailable, or whose
|
|
81
71
|
`enabled` flag is false, are excluded. For ordinary phase work, Jev sees the seat's active work and
|
|
82
|
-
selects the weakest available model sufficient for that responsibility. If model selection is
|
|
83
|
-
or low-confidence, Workflow conservatively
|
|
84
|
-
review has its own bounded routing step described below. Older Desks without
|
|
85
|
-
to honor a legacy seat `agent` through the executable registry.
|
|
72
|
+
selects the weakest available configured Pi model sufficient for that responsibility. If model selection is
|
|
73
|
+
unavailable or low-confidence, Workflow conservatively selects the strongest available configured Pi
|
|
74
|
+
model. Final behavioral review has its own bounded routing step described below. Older Desks without
|
|
75
|
+
`models.yaml` continue to honor a legacy seat `agent` through the executable registry.
|
|
86
76
|
|
|
87
77
|
## Configure worktrees and seats
|
|
88
78
|
|
|
@@ -124,9 +114,7 @@ atdd-flow task add resolver-os runtime-rollout --title 'Complete runtime rollout
|
|
|
124
114
|
atdd-flow task start resolver-os runtime-rollout --by driver.runtime@resolver-os
|
|
125
115
|
atdd-flow task prove resolver-os runtime-rollout --by driver.runtime@resolver-os --item 1 --proof 'CI run 42'
|
|
126
116
|
atdd-flow task review resolver-os runtime-rollout --by driver.runtime@resolver-os
|
|
127
|
-
|
|
128
|
-
--application tuios --placement resolver-os --gate 'CI run 42'
|
|
129
|
-
# the reviewer persists APPROVE, RETURN, or ESCALATE through behavioral-review record
|
|
117
|
+
# An independently attached reviewer persists APPROVE, RETURN, or ESCALATE through behavioral-review record
|
|
130
118
|
atdd-flow task done resolver-os runtime-rollout --by coordinator@resolver-os
|
|
131
119
|
```
|
|
132
120
|
|
|
@@ -140,10 +128,19 @@ Threads are the durable inbox/outbox. Workflow persists a message before a best-
|
|
|
140
128
|
atdd-flow thread start --with coordinator@resolver-os,driver.runtime@resolver-os \
|
|
141
129
|
--subject 'Runtime rollout' --task resolver-os/runtime-rollout
|
|
142
130
|
atdd-flow post T-... --from coordinator@resolver-os --to driver.runtime@resolver-os \
|
|
143
|
-
--expects-result --body 'Implement the task and return proof references.'
|
|
144
|
-
atdd-flow result T-... M-... --from driver.runtime@resolver-os --body 'CI run 42; PR #81.'
|
|
131
|
+
--label 'implementation request' --expects-result --body 'Implement the task and return proof references.'
|
|
132
|
+
atdd-flow result T-... M-... --from driver.runtime@resolver-os --label 'proof returned' --body 'CI run 42; PR #81.'
|
|
145
133
|
```
|
|
146
134
|
|
|
135
|
+
New durable thread and message IDs are human-readable: `T-` or `M-`, a UTC-second
|
|
136
|
+
stamp (`YYYYMMDDTHHMMSSZ`), a kebab-case slug, and a short random suffix, such as
|
|
137
|
+
`M-20261010T122049Z-rollover-review_7e5e12ab`. Thread slugs derive from `--subject`.
|
|
138
|
+
`post`, `receipt`, and `result` optionally accept `--label` for the message slug; it
|
|
139
|
+
defaults to the message kind (`message`, `receipt`, or `result`). Labels are durable
|
|
140
|
+
metadata, so provide only non-sensitive text. Message bodies are never used in IDs.
|
|
141
|
+
Existing Desk thread and message filenames retain their legacy IDs unchanged and remain
|
|
142
|
+
readable.
|
|
143
|
+
|
|
147
144
|
For a shared boundary: driver → coordinator → affected coordinator(s) → minimum agreement in a thread → driver. `--to all` broadcasts; requested results remain outstanding until every recipient replies. Checkpoints are short handoffs, not logs; update at responsibility transitions and before replacing an agent.
|
|
148
145
|
|
|
149
146
|
## Inspect, host, and guide agents
|
|
@@ -154,24 +151,38 @@ atdd-flow status seat driver.runtime@resolver-os
|
|
|
154
151
|
atdd-flow open driver.runtime@resolver-os
|
|
155
152
|
```
|
|
156
153
|
|
|
157
|
-
|
|
154
|
+
Herdr and tmux can notify an already attached host pane. Flow does not create host panes: the operator starts the agent in its declared worktree with `ATDD_WORKFLOW_ROOT` and `ATDD_WORKFLOW_SEAT`, then attaches that pane deterministically.
|
|
158
155
|
|
|
159
156
|
```sh
|
|
160
|
-
atdd-flow attach driver.runtime@resolver-os --application
|
|
161
|
-
atdd-flow launch driver.runtime@resolver-os --application tuios --placement resolver-os
|
|
157
|
+
atdd-flow attach driver.runtime@resolver-os --application herdr
|
|
162
158
|
```
|
|
163
159
|
|
|
164
|
-
|
|
160
|
+
### Optional Herdr worktree projection
|
|
161
|
+
|
|
162
|
+
`multiplexer/herdr.yaml` is a compact, instance-free policy: it describes only primary versus linked worktree role placement. The Desk remains authoritative for projects, seats, tasks, branches, and worktrees. Herdr is an optional display/runtime projection and never becomes a second registry.
|
|
163
|
+
|
|
164
|
+
An operator must select the target session explicitly, or run from a Herdr pane that supplies `HERDR_SESSION`; Flow never chooses a Desk-wide or focused session. Status is read-only and apply uses `--no-focus`:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
atdd-flow multiplexer status herdr --session forge
|
|
168
|
+
atdd-flow multiplexer apply herdr --session forge
|
|
169
|
+
# Inside a Herdr pane, the inherited HERDR_SESSION is sufficient:
|
|
170
|
+
atdd-flow multiplexer apply herdr
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
For each Desk project, apply reconciles the declared repository checkout as workspace `{project}` and gives every main/coordinator seat using it a tab and pane named `{seat.address}`. Linked coordinator worktrees and drivers with a non-done assigned task become linked-worktree workspaces named `{seat.address}`, with an equally named tab and pane. Unbound worktrees are ignored. Apply neither focuses, closes, nor guesses about other workspaces or sessions.
|
|
174
|
+
|
|
175
|
+
New Herdr attachments store both the inherited session and pane id. Older scalar pane bindings remain readable and use their legacy Desk session only when one exists, so a bare `w1:p1` from one session cannot be mistaken for the same pane in another newly attached session.
|
|
165
176
|
|
|
166
177
|
### Pi-native Desk mail
|
|
167
178
|
|
|
168
|
-
When
|
|
179
|
+
When Pi starts with Flow’s bundled extension, it remains a normal, interactive agent in its host pane, but the extension watches the immutable Desk mail files and wakes Pi internally with `pi.sendMessage()`—not terminal text injection.
|
|
169
180
|
|
|
170
181
|
The Pi seat records both concepts independently:
|
|
171
182
|
|
|
172
183
|
```yaml
|
|
173
184
|
runtime:
|
|
174
|
-
application: herdr
|
|
185
|
+
application: herdr
|
|
175
186
|
addresses:
|
|
176
187
|
herdr: w9:p1
|
|
177
188
|
model: pi
|
|
@@ -188,7 +199,11 @@ atdd-flow message read M-...
|
|
|
188
199
|
|
|
189
200
|
The output includes only the message and its thread ID/subject; Flow rejects missing or ambiguous message IDs.
|
|
190
201
|
|
|
191
|
-
Pi’s native wake-up is intentionally lightweight: no daemon, duplicate mailbox, or separate extension installation. The extension is shipped inside the Flow package.
|
|
202
|
+
Pi’s native wake-up is intentionally lightweight: no daemon, duplicate mailbox, or separate extension installation. The extension is shipped inside the Flow package. Desk thread files remain the source of truth.
|
|
203
|
+
|
|
204
|
+
At session start, the extension records one local, token-fenced **advisory** runtime observation at `.atdd-flow/pi-runtime/<encoded-seat>.yaml`. It contains only its opaque owner token, PID, model, cwd, start time, and heartbeat. A replacement Pi process atomically supersedes the prior observation; the old process can neither heartbeat, clear, nor consume queued mail after it loses its token. Shutdown and expired-heartbeat cleanup remove only the matching advisory observation. This file never selects recipients or changes a Desk seat, task, thread, checkpoint, branch, worktree, or immutable mail. `atdd-flow status seat` may display its active/stale activity as read-only information.
|
|
205
|
+
|
|
206
|
+
Native mail is loss-tolerant rather than watcher-dependent. When Flow persists a message for a native or Pi-designated unbound seat, it also appends a small per-seat reference to an ordered, fixed-size pending segment under `.atdd-flow/pi-inbox/<encoded-seat>`; immutable thread `M-*.yaml` files remain the authoritative message source. On session start/reload and at a bounded interval, the extension reads only the durable queue head and consumes a fixed batch in `created_at`/message-ID order, removing a reference only after Pi accepts delivery. The pending queue shrinks after delivery, so periodic recovery neither scans nor retains the complete Desk history. `fs.watch` on that queue remains a low-latency fast path; an unavailable watcher or restarted Pi is recovered by reconciliation. Existing Pi processes still require `/reload` or restart after a Flow package upgrade to load the bundled extension.
|
|
192
207
|
|
|
193
208
|
To host Pi in Herdr, create a pane with the durable Desk and seat identity, then start Pi with the extension supplied by the installed Flow package:
|
|
194
209
|
|
|
@@ -202,7 +217,9 @@ herdr agent start pi-runtime --kind pi --pane <pane-id> -- --extension "$PI_EXTE
|
|
|
202
217
|
atdd-flow --root /path/to/desk attach driver.runtime@resolver-os --application herdr --wake native
|
|
203
218
|
```
|
|
204
219
|
|
|
205
|
-
Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API.
|
|
220
|
+
Pi receives its identity and startup task from the extension itself. Its normal TUI remains visible and manually usable; incoming Desk mail wakes it through Pi's native message API. The runtime lifecycle has one internal bounded inbox transport; it does not add a second watcher, registry, mailbox, reservation system, or routing path.
|
|
221
|
+
|
|
222
|
+
The lifecycle and recovery design was informed by [nicobailon/pi-messenger](https://github.com/nicobailon/pi-messenger) at `09937ed647a1b07a3b595bf75943feacb80ff123` (MIT). No pi-messenger source is copied here. Flow keeps Desk identity, recipient selection, immutable mail, and governance authoritative; any future copied source must carry its MIT notice.
|
|
206
223
|
|
|
207
224
|
With ATDD Bun, enable the Workflow profile:
|
|
208
225
|
|
package/extensions/pi/index.ts
CHANGED
|
@@ -1,18 +1,54 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { parse } from "yaml";
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { clearStaleRuntimeState, clearRuntimeState, heartbeatRuntimeState, ownsRuntimeState, registerRuntimeState, runtimeOwnerToken, withOwnedRuntimeState } from "../../src/runtime-state";
|
|
6
7
|
|
|
7
|
-
type Thread = { participants?: unknown; subject?: unknown };
|
|
8
|
-
type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown };
|
|
8
|
+
type Thread = { id?: unknown; participants?: unknown; subject?: unknown };
|
|
9
|
+
export type Mail = { id?: unknown; from?: unknown; to?: unknown; subject?: unknown; created_at?: unknown };
|
|
10
|
+
type InboxMail = Mail & { id: string; created_at: string };
|
|
11
|
+
type PendingReference = { schema?: unknown; thread?: unknown; message?: unknown; created_at?: unknown };
|
|
12
|
+
type PendingSegment = { schema?: unknown; entries?: unknown; next?: unknown };
|
|
13
|
+
type PendingQueue = { schema?: unknown; head?: unknown; tail?: unknown };
|
|
14
|
+
type WakeAction = () => void | Promise<void>;
|
|
15
|
+
type SendIfOwned = (action: WakeAction) => Promise<boolean>;
|
|
16
|
+
type InboxOptions = {
|
|
17
|
+
root: string;
|
|
18
|
+
seat: string;
|
|
19
|
+
/** Return false when this runtime has been replaced; the durable reference remains queued. */
|
|
20
|
+
deliver: (mail: InboxMail, thread: Thread, path: string, sendIfOwned?: SendIfOwned) => boolean | void | Promise<boolean | void>;
|
|
21
|
+
batchSize?: number;
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
};
|
|
9
24
|
|
|
10
|
-
|
|
25
|
+
/** The sole internal delivery transport: one durable queue with watcher-as-fast-path recovery. */
|
|
26
|
+
export type FlowInboxTransport = {
|
|
27
|
+
reconcile: () => Promise<void>;
|
|
28
|
+
start: () => Promise<void>;
|
|
29
|
+
stop: () => void;
|
|
30
|
+
watermarkFile: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type PiRuntimeOptions = {
|
|
34
|
+
root: string;
|
|
35
|
+
seat: string;
|
|
36
|
+
pid?: number;
|
|
37
|
+
model?: string;
|
|
38
|
+
cwd?: string;
|
|
39
|
+
heartbeatMs?: number;
|
|
40
|
+
staleAfterMs?: number;
|
|
41
|
+
deliver: InboxOptions["deliver"];
|
|
42
|
+
createTransport?: (options: InboxOptions) => FlowInboxTransport;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const defaultBatchSize = 32;
|
|
46
|
+
const defaultIntervalMs = 30_000;
|
|
47
|
+
const defaultHeartbeatMs = 15_000;
|
|
48
|
+
const defaultStaleAfterMs = 60_000;
|
|
11
49
|
|
|
12
50
|
function participants(thread: Thread) {
|
|
13
|
-
return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string")
|
|
14
|
-
? thread.participants as string[]
|
|
15
|
-
: [];
|
|
51
|
+
return Array.isArray(thread.participants) && thread.participants.every((entry) => typeof entry === "string") ? thread.participants as string[] : [];
|
|
16
52
|
}
|
|
17
53
|
|
|
18
54
|
export function addressedTo(mail: Mail, thread: Thread, seat: string) {
|
|
@@ -30,91 +66,226 @@ export function mailNotice(threadId: string, mail: Mail) {
|
|
|
30
66
|
return `SYSTEM: Flow mail ${id} | thread ${threadId}${subject ? ` (${subject})` : ""} | ${from} → ${recipients}. Read: atdd-flow message read ${id}`;
|
|
31
67
|
}
|
|
32
68
|
|
|
69
|
+
function inboxDirectory(root: string, seat: string) {
|
|
70
|
+
return join(root, ".atdd-flow", "pi-inbox", encodeURIComponent(seat));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pendingDirectory(root: string, seat: string) { return join(inboxDirectory(root, seat), "pending"); }
|
|
74
|
+
function queuePath(root: string, seat: string) { return join(inboxDirectory(root, seat), "queue.yaml"); }
|
|
75
|
+
function segmentPath(root: string, seat: string, segment: string) { return join(pendingDirectory(root, seat), `${segment}.yaml`); }
|
|
76
|
+
|
|
77
|
+
async function atomicYaml(path: string, value: unknown) {
|
|
78
|
+
await mkdir(dirname(path), { recursive: true });
|
|
79
|
+
const temporary = join(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
|
|
80
|
+
await writeFile(temporary, stringify(value), "utf8");
|
|
81
|
+
await rename(temporary, path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
85
|
+
|
|
86
|
+
async function withInboxLock<T>(directory: string, action: () => Promise<T>) {
|
|
87
|
+
await mkdir(directory, { recursive: true });
|
|
88
|
+
const lock = join(directory, "queue.lock");
|
|
89
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(lock);
|
|
92
|
+
try { return await action(); }
|
|
93
|
+
finally { await rm(lock, { recursive: true, force: true }); }
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
96
|
+
try {
|
|
97
|
+
if (Date.now() - (await stat(lock)).mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
|
|
98
|
+
else await pause(5);
|
|
99
|
+
} catch { await pause(5); }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Timed out waiting for inbox queue lock ${directory}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validMail(value: Mail): value is InboxMail {
|
|
106
|
+
return typeof value.id === "string" && typeof value.created_at === "string";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validReference(value: PendingReference): value is Required<Pick<PendingReference, "thread" | "message" | "created_at">> {
|
|
110
|
+
return typeof value.thread === "string" && typeof value.message === "string" && typeof value.created_at === "string";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validQueue(value: PendingQueue): value is Required<Pick<PendingQueue, "head">> {
|
|
114
|
+
return typeof value.head === "string";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function references(value: PendingSegment): PendingReference[] {
|
|
118
|
+
return Array.isArray(value.entries) ? value.entries.filter((entry): entry is PendingReference => Boolean(entry) && typeof entry === "object") : [];
|
|
119
|
+
}
|
|
120
|
+
|
|
33
121
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
122
|
+
* Reconciles a bounded, durable queue of immutable Desk-message references.
|
|
123
|
+
* The queue is appended when Flow persists mail, so recovery never rescans historical threads.
|
|
36
124
|
*/
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (!root || !seat) return;
|
|
41
|
-
|
|
42
|
-
const threads = join(root, "threads");
|
|
125
|
+
export function createInboxTransport({ root, seat, deliver, batchSize = defaultBatchSize, intervalMs = defaultIntervalMs }: InboxOptions): FlowInboxTransport {
|
|
126
|
+
let watcher: fs.FSWatcher | undefined;
|
|
127
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
43
128
|
const delivered = new Set<string>();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
129
|
+
let serial = Promise.resolve();
|
|
130
|
+
|
|
131
|
+
const reconcileNow = async () => withInboxLock(inboxDirectory(root, seat), async () => {
|
|
132
|
+
let queue: PendingQueue;
|
|
133
|
+
try { queue = parse(await readFile(queuePath(root, seat), "utf8")) as PendingQueue; }
|
|
134
|
+
catch { return; }
|
|
135
|
+
if (!validQueue(queue)) return;
|
|
136
|
+
const path = segmentPath(root, seat, queue.head);
|
|
137
|
+
let segment: PendingSegment;
|
|
138
|
+
try { segment = parse(await readFile(path, "utf8")) as PendingSegment; }
|
|
139
|
+
catch { return; }
|
|
140
|
+
const entries = references(segment);
|
|
141
|
+
// A segment contains at most 32 references. A tick reads one durable head segment and at most batchSize mail records.
|
|
142
|
+
for (let count = 0; count < batchSize && entries.length; count += 1) {
|
|
143
|
+
const reference = entries[0];
|
|
144
|
+
if (!validReference(reference)) { entries.shift(); await atomicYaml(path, { ...segment, entries }); continue; }
|
|
145
|
+
const key = `${reference.thread}/${reference.message}`;
|
|
53
146
|
try {
|
|
54
|
-
const folder = join(threads,
|
|
147
|
+
const folder = join(root, "threads", reference.thread);
|
|
55
148
|
const [rawThread, rawMail] = await Promise.all([
|
|
56
149
|
readFile(join(folder, "thread.yaml"), "utf8"),
|
|
57
|
-
readFile(join(folder,
|
|
150
|
+
readFile(join(folder, `${reference.message}.yaml`), "utf8"),
|
|
58
151
|
]);
|
|
59
152
|
const thread = parse(rawThread) as Thread;
|
|
60
153
|
const mail = parse(rawMail) as Mail;
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
154
|
+
if (validMail(mail) && addressedTo(mail, thread, seat) && !delivered.has(key)) {
|
|
155
|
+
const accepted = await deliver(mail, thread, join(folder, `${reference.message}.yaml`));
|
|
156
|
+
// A replaced runtime must not consume the shared durable reference.
|
|
157
|
+
if (accepted === false) return;
|
|
158
|
+
}
|
|
64
159
|
delivered.add(key);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
160
|
+
entries.shift();
|
|
161
|
+
await atomicYaml(path, { ...segment, entries });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// A crash before message persistence leaves an unpublished prepare record. Retain it briefly for an in-flight post, then discard only the non-authoritative orphan.
|
|
164
|
+
const age = Date.now() - Date.parse(reference.created_at);
|
|
165
|
+
if (reference.published !== true && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
166
|
+
entries.shift();
|
|
167
|
+
if (Number.isFinite(age) && age > 30_000) {
|
|
168
|
+
await atomicYaml(path, { ...segment, entries });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// A fresh pre-message intent is not mail. Move it behind ready entries so a crashed writer never head-of-line blocks durable Desk mail.
|
|
172
|
+
entries.push(reference);
|
|
173
|
+
await atomicYaml(path, { ...segment, entries });
|
|
174
|
+
}
|
|
175
|
+
// Keep authoritative references until Pi accepts them; a fresh intent is retried on the next bounded tick.
|
|
176
|
+
return;
|
|
75
177
|
}
|
|
76
|
-
}, 25);
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const watchThread = (threadId: string) => {
|
|
80
|
-
if (threadWatchers.has(threadId)) return;
|
|
81
|
-
try {
|
|
82
|
-
const watcher = fs.watch(join(threads, threadId), (_event, file) => {
|
|
83
|
-
const name = file?.toString();
|
|
84
|
-
if (name && finalMail.test(name)) deliver(threadId, name);
|
|
85
|
-
});
|
|
86
|
-
threadWatchers.set(threadId, watcher);
|
|
87
|
-
} catch {
|
|
88
|
-
// A concurrent thread deletion is harmless.
|
|
89
178
|
}
|
|
179
|
+
if (entries.length) return;
|
|
180
|
+
const next = typeof segment.next === "string" ? segment.next : undefined;
|
|
181
|
+
await unlink(path);
|
|
182
|
+
if (next) {
|
|
183
|
+
await atomicYaml(queuePath(root, seat), { ...queue, head: next });
|
|
184
|
+
} else {
|
|
185
|
+
await unlink(queuePath(root, seat));
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const reconcile = () => {
|
|
190
|
+
const scheduled = serial.then(reconcileNow, reconcileNow);
|
|
191
|
+
serial = scheduled.catch(() => undefined);
|
|
192
|
+
return scheduled;
|
|
193
|
+
};
|
|
194
|
+
const start = async () => {
|
|
195
|
+
await reconcile();
|
|
196
|
+
try { watcher = fs.watch(pendingDirectory(root, seat), () => { void reconcile(); }); }
|
|
197
|
+
catch { /* Periodic reconciliation recovers unavailable watchers. */ }
|
|
198
|
+
interval = setInterval(() => { void reconcile(); }, intervalMs);
|
|
199
|
+
};
|
|
200
|
+
const stop = () => {
|
|
201
|
+
watcher?.close();
|
|
202
|
+
watcher = undefined;
|
|
203
|
+
if (interval) clearInterval(interval);
|
|
204
|
+
interval = undefined;
|
|
90
205
|
};
|
|
206
|
+
return { reconcile, start, stop, watermarkFile: pendingDirectory(root, seat) };
|
|
207
|
+
}
|
|
91
208
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
209
|
+
/** Backward-compatible name for the one internal FlowInboxTransport implementation. */
|
|
210
|
+
export const createInboxReconciler = createInboxTransport;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Binds a Pi process lifecycle to one non-authoritative, token-fenced runtime
|
|
214
|
+
* observation. Losing the token stops this instance from consuming mail; the
|
|
215
|
+
* single durable inbox transport remains the only delivery path.
|
|
216
|
+
*/
|
|
217
|
+
export function createPiRuntime({
|
|
218
|
+
root, seat, pid = process.pid, model = process.env.PI_MODEL ?? "pi", cwd = process.cwd(),
|
|
219
|
+
heartbeatMs = defaultHeartbeatMs, staleAfterMs = defaultStaleAfterMs, deliver, createTransport = createInboxTransport,
|
|
220
|
+
}: PiRuntimeOptions) {
|
|
221
|
+
const ownerToken = runtimeOwnerToken();
|
|
222
|
+
let transport: FlowInboxTransport | undefined;
|
|
223
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
224
|
+
|
|
225
|
+
const owned = () => ownsRuntimeState(root, seat, ownerToken);
|
|
226
|
+
const stop = async () => {
|
|
227
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
228
|
+
heartbeat = undefined;
|
|
229
|
+
transport?.stop();
|
|
230
|
+
transport = undefined;
|
|
231
|
+
await clearRuntimeState(root, seat, ownerToken);
|
|
232
|
+
};
|
|
233
|
+
const reconcile = async () => {
|
|
234
|
+
if (!transport || !await owned()) return;
|
|
235
|
+
await transport.reconcile();
|
|
236
|
+
};
|
|
237
|
+
const start = async () => {
|
|
238
|
+
// This removes only an expired local observation. Registration itself is
|
|
239
|
+
// replacement-safe and does not alter the authoritative Desk seat.
|
|
240
|
+
await clearStaleRuntimeState(root, seat, staleAfterMs);
|
|
241
|
+
await registerRuntimeState(root, { seat, owner_token: ownerToken, pid, model, cwd });
|
|
242
|
+
transport = createTransport({
|
|
243
|
+
root, seat, batchSize: defaultBatchSize, intervalMs: defaultIntervalMs,
|
|
244
|
+
deliver: async (mail, thread, path) => {
|
|
245
|
+
// The application supplies the wake action, but this shared filesystem
|
|
246
|
+
// lock performs the final ownership check immediately around it.
|
|
247
|
+
let ownership: boolean | undefined;
|
|
248
|
+
const accepted = await deliver(mail, thread, path, async (wake) => {
|
|
249
|
+
if (ownership !== undefined) return false;
|
|
250
|
+
const result = await withOwnedRuntimeState(root, seat, ownerToken, wake);
|
|
251
|
+
ownership = result.owned;
|
|
252
|
+
return result.owned;
|
|
253
|
+
});
|
|
254
|
+
// A callback cannot accidentally consume mail by ignoring a rejected
|
|
255
|
+
// authorization result or by attempting more than one wake action.
|
|
256
|
+
return ownership === true && accepted !== false;
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
await transport.start();
|
|
260
|
+
heartbeat = setInterval(() => {
|
|
261
|
+
void heartbeatRuntimeState(root, seat, ownerToken).then((state) => {
|
|
262
|
+
if (!state) void stop();
|
|
263
|
+
});
|
|
264
|
+
}, heartbeatMs);
|
|
100
265
|
};
|
|
266
|
+
return { ownerToken, start, stop, reconcile, get transport() { return transport; } };
|
|
267
|
+
}
|
|
101
268
|
|
|
269
|
+
/** Desk mail is authoritative; the queue is a bounded delivery index and fs.watch only shortens latency. */
|
|
270
|
+
export default function (pi: ExtensionAPI) {
|
|
271
|
+
const root = process.env.ATDD_WORKFLOW_ROOT;
|
|
272
|
+
const seat = process.env.ATDD_WORKFLOW_SEAT;
|
|
273
|
+
if (!root || !seat) return;
|
|
274
|
+
let runtime: ReturnType<typeof createPiRuntime> | undefined;
|
|
102
275
|
pi.on("session_start", async (_event, ctx) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
276
|
+
runtime = createPiRuntime({ root, seat, deliver: async (mail, thread, path, sendIfOwned) => {
|
|
277
|
+
if (!sendIfOwned) return false;
|
|
278
|
+
return sendIfOwned(() => {
|
|
279
|
+
pi.sendMessage({
|
|
280
|
+
customType: "atdd-flow-mail",
|
|
281
|
+
content: mailNotice(typeof thread.id === "string" ? thread.id : "unknown", { ...mail, subject: typeof thread.subject === "string" ? thread.subject : undefined }),
|
|
282
|
+
display: true, details: { thread: thread.id, message: mail.id, path },
|
|
283
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
284
|
+
});
|
|
285
|
+
} });
|
|
286
|
+
await runtime.start();
|
|
287
|
+
pi.sendMessage({ customType: "atdd-flow-start", content: `SYSTEM: you are ${seat}. Read your durable seat and assigned work with: atdd-flow open ${seat}. Continue assigned in_progress work until it is review-ready or explicitly blocked.`, display: true, details: { seat, root } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
112
288
|
if (ctx.hasUI) ctx.ui.notify(`ATDD Flow native mail active for ${seat}`, "info");
|
|
113
289
|
});
|
|
114
|
-
|
|
115
|
-
pi.on("session_shutdown", () => {
|
|
116
|
-
threadsWatcher?.close();
|
|
117
|
-
for (const watcher of threadWatchers.values()) watcher.close();
|
|
118
|
-
threadWatchers.clear();
|
|
119
|
-
});
|
|
290
|
+
pi.on("session_shutdown", () => { void runtime?.stop(); });
|
|
120
291
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
schema: atdd-workflow/multiplexer/herdr/v1
|
|
2
|
+
projection:
|
|
3
|
+
primary:
|
|
4
|
+
worktree: primary
|
|
5
|
+
workspace_label: "{project}"
|
|
6
|
+
seats:
|
|
7
|
+
roles: [main, coordinator]
|
|
8
|
+
tab_label: "{seat.address}"
|
|
9
|
+
pane_label: "{seat.address}"
|
|
10
|
+
linked_worktree:
|
|
11
|
+
worktree: linked
|
|
12
|
+
seats:
|
|
13
|
+
coordinator: always
|
|
14
|
+
driver: active_or_assigned
|
|
15
|
+
workspace_label: "{seat.address}"
|
|
16
|
+
tab_label: "{seat.address}"
|
|
17
|
+
pane_label: "{seat.address}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afokapu/atdd-flow",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"extensions",
|
|
18
18
|
"src",
|
|
19
19
|
"README.md",
|
|
20
|
-
"LICENSE"
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"multiplexer"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"atdd",
|
package/src/adapters.ts
CHANGED
|
@@ -17,13 +17,17 @@ export function discoverAddress(application: string, environment: Environment =
|
|
|
17
17
|
const discoverers: Record<string, () => string> = {
|
|
18
18
|
herdr: () => value(environment, "HERDR_PANE_ID", "Herdr"),
|
|
19
19
|
tmux: () => value(environment, "TMUX_PANE", "tmux"),
|
|
20
|
-
tuios: () => `${value(environment, "TUIOS_SESSION", "TUIOS")}/${value(environment, "TUIOS_WINDOW_ID", "TUIOS")}`,
|
|
21
20
|
};
|
|
22
21
|
const discover = discoverers[application];
|
|
23
22
|
if (!discover) throw new Error(`No deterministic discovery adapter is installed for ${application}.`);
|
|
24
23
|
return discover();
|
|
25
24
|
}
|
|
26
25
|
|
|
26
|
+
/** Herdr pane ids are only unique inside a session, so new bindings record both. */
|
|
27
|
+
export function discoverHerdrLocator(environment: Environment = process.env) {
|
|
28
|
+
return { session: value(environment, "HERDR_SESSION", "Herdr"), pane: value(environment, "HERDR_PANE_ID", "Herdr") };
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
export function notificationCommand(application: string, address: string, notice: string, herdrSession?: string): string[] | undefined {
|
|
28
32
|
const commands: Record<string, () => string[] | undefined> = {
|
|
29
33
|
tmux: () => ["tmux", "send-keys", "-t", address, notice, "Enter"],
|
|
@@ -32,11 +36,6 @@ export function notificationCommand(application: string, address: string, notice
|
|
|
32
36
|
// pane text injection: a Desk message remains durable when an older host
|
|
33
37
|
// cannot provide agent.prompt.
|
|
34
38
|
herdr: () => ["herdr", ...(herdrSession ? ["--session", herdrSession] : []), "agent", "prompt", address, notice],
|
|
35
|
-
tuios: () => {
|
|
36
|
-
const separator = address.indexOf("/");
|
|
37
|
-
if (separator < 1 || separator === address.length - 1) return undefined;
|
|
38
|
-
return ["tuios", "queue", "-s", address.slice(0, separator), "-w", address.slice(separator + 1), notice];
|
|
39
|
-
},
|
|
40
39
|
};
|
|
41
40
|
return commands[application]?.();
|
|
42
41
|
}
|
|
@@ -53,33 +52,13 @@ export type LaunchRequest = {
|
|
|
53
52
|
seat: string;
|
|
54
53
|
};
|
|
55
54
|
|
|
56
|
-
/**
|
|
57
|
-
export function launchCommand(
|
|
58
|
-
|
|
59
|
-
"/usr/bin/env",
|
|
60
|
-
`ATDD_WORKFLOW_ROOT=${request.root}`,
|
|
61
|
-
`ATDD_WORKFLOW_SEAT=${request.seat}`,
|
|
62
|
-
...Object.entries(request.environment ?? {}).map(([key, value]) => `${key}=${value}`),
|
|
63
|
-
request.agent,
|
|
64
|
-
...(request.args ?? []),
|
|
65
|
-
];
|
|
66
|
-
if (request.application === "tuios") {
|
|
67
|
-
return [
|
|
68
|
-
"tuios", "new-window", request.name,
|
|
69
|
-
"-s", request.placement,
|
|
70
|
-
"--cwd", request.worktree,
|
|
71
|
-
"--no-focus", "--print-id", "--",
|
|
72
|
-
...environment,
|
|
73
|
-
];
|
|
74
|
-
}
|
|
75
|
-
throw new Error(`No deterministic launch adapter is installed for ${request.application}.`);
|
|
55
|
+
/** Flow no longer owns host-pane creation; attach a host-created pane instead. */
|
|
56
|
+
export function launchCommand(_request: LaunchRequest): string[] {
|
|
57
|
+
throw new Error("No deterministic launch adapter is installed.");
|
|
76
58
|
}
|
|
77
59
|
|
|
78
|
-
export function launchedAddress(
|
|
79
|
-
|
|
80
|
-
const window = output.split(/\s+/)[0];
|
|
81
|
-
if (!window) throw new Error("TUIOS created a window without returning its id.");
|
|
82
|
-
return `${placement}/${window}`;
|
|
60
|
+
export function launchedAddress(_application: string, _placement: string, _output: string): string {
|
|
61
|
+
throw new Error("No deterministic launch adapter is installed.");
|
|
83
62
|
}
|
|
84
63
|
|
|
85
64
|
export async function notify(application: string, address: string, notice: string, herdrSession?: string) {
|