@oisin-ee/pi-dogsled 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/package.json +6 -6
- package/pi/index.ts +15 -3
- package/pi/unit-bridge.ts +174 -0
- package/pi/unit-protocol.ts +85 -0
- package/pi/unit-worker.ts +93 -0
package/README.md
CHANGED
|
@@ -11,13 +11,13 @@ Install the binary on each Herdr host. Use the same release tag in each command.
|
|
|
11
11
|
Binary:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
mise use github:oisin-ee/dogsled@v0.
|
|
14
|
+
mise use github:oisin-ee/dogsled@v0.16.0
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Pi (extension, tools, and skill), from npm or as part of `@oisin-ee/oisinpi`:
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
pi install npm:@oisin-ee/pi-dogsled@0.
|
|
20
|
+
pi install npm:@oisin-ee/pi-dogsled@0.16.0
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
At session start the extension compares its version with `dogsled --version` and warns when they differ.
|
|
@@ -25,14 +25,14 @@ At session start the extension compares its version with `dogsled --version` and
|
|
|
25
25
|
Claude Code (hooks, tools, and skill):
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
claude plugin marketplace add oisin-ee/dogsled#v0.
|
|
28
|
+
claude plugin marketplace add oisin-ee/dogsled#v0.16.0
|
|
29
29
|
claude plugin install dogsled@dogsled
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Codex (hooks, tools, and skill):
|
|
33
33
|
|
|
34
34
|
```sh
|
|
35
|
-
codex plugin marketplace add oisin-ee/dogsled --ref v0.
|
|
35
|
+
codex plugin marketplace add oisin-ee/dogsled --ref v0.16.0
|
|
36
36
|
codex plugin add dogsled@dogsled
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -42,7 +42,7 @@ The [Codex CLI reference](https://developers.openai.com/codex/cli/reference#code
|
|
|
42
42
|
Herdr (status-board popup):
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
|
-
herdr plugin install oisin-ee/dogsled --ref v0.
|
|
45
|
+
herdr plugin install oisin-ee/dogsled --ref v0.16.0 --yes
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Harness plugins use the binary on `PATH`. They do not install it or start host services.
|
|
@@ -87,8 +87,10 @@ dogsled items --owner "$HERDR_PANE_ID" --json
|
|
|
87
87
|
dogsled cancel TICKET
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
With `--branch`, an agent uses the pane of the worktree's new Herdr workspace.
|
|
91
|
+
Other work in a repository opens a new tab where that repository is shown: the workspace of its worktree, else the main checkout's workspace.
|
|
92
|
+
When no workspace shows the repository, dogsled opens a new workspace for it.
|
|
93
|
+
Work outside any repository uses a new tab in the caller's workspace. On a remote host, that is the workspace with the caller's workspace name, created when absent.
|
|
92
94
|
Without a branch, a remote non-repository `--cwd` must exist there.
|
|
93
95
|
Repository directories map to the same path relative to home. A linked-worktree caller needs `--branch` to preserve its branch remotely.
|
|
94
96
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oisin-ee/pi-dogsled",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pi extension for dogsled: the board, the /dogsled panel, the inbox reader, and the dogsled tools. Needs the dogsled binary of the same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"orchestration"
|
|
30
30
|
],
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=22.19.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": "
|
|
48
|
-
"@earendil-works/pi-tui": "
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
48
|
+
"@earendil-works/pi-tui": "^1.3.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-coding-agent": "1.0
|
|
52
|
-
"@earendil-works/pi-tui": "1.0
|
|
51
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
52
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
53
53
|
"@types/node": "26.6.4",
|
|
54
54
|
"typescript": "7.0.2"
|
|
55
55
|
}
|
package/pi/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
import { Box, Text, Key } from "@earendil-works/pi-tui";
|
|
4
4
|
import { type Board, clean, parseBoard } from "./board.ts";
|
|
5
5
|
import { WorkTodo } from "./work-todo.ts";
|
|
6
|
+
import { UnitBridge } from "./unit-bridge.ts";
|
|
6
7
|
import { workBoard, workText, type WorkState } from "./work.ts";
|
|
7
8
|
import { type Details, InboxReader, MESSAGE_TYPE } from "./inbox.ts";
|
|
8
9
|
import { openPanel } from "./panel.ts";
|
|
@@ -46,6 +47,9 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
const inbox = new InboxReader(pi, exec);
|
|
50
|
+
const units = new UnitBridge(pi, binary);
|
|
51
|
+
pi.on("tool_call", (event, ctx) => units.call(event, ctx));
|
|
52
|
+
pi.on("tool_execution_end", (event, ctx) => units.ended(event.toolCallId, ctx));
|
|
49
53
|
|
|
50
54
|
pi.registerMessageRenderer<Details>(MESSAGE_TYPE, (message, { expanded, outputPad }, theme) => {
|
|
51
55
|
const records = message.details?.records ?? [];
|
|
@@ -168,6 +172,7 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
168
172
|
const current = generation;
|
|
169
173
|
refresh = (async () => {
|
|
170
174
|
await inbox.drain(ctx);
|
|
175
|
+
try { await units.ready(ctx); await units.drain(ctx); } catch (error) { ctx.ui.notify(`Dogsled settlement delivery pending: ${String(error)}`, "warning"); }
|
|
171
176
|
if (ctx.mode !== "tui") return;
|
|
172
177
|
try {
|
|
173
178
|
const next = parseBoard(JSON.parse(await exec(["board", "--json"])));
|
|
@@ -192,7 +197,8 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
192
197
|
return refresh;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
pi.on("session_start", async (
|
|
200
|
+
pi.on("session_start", async (event, ctx) => {
|
|
201
|
+
units.start(event.reason);
|
|
196
202
|
if (ctx.mode === "tui") ctx.ui.setWidget("dogsled", undefined);
|
|
197
203
|
requestWorkRender = undefined;
|
|
198
204
|
workPanel?.dispose();
|
|
@@ -222,6 +228,7 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
222
228
|
await update(ctx);
|
|
223
229
|
});
|
|
224
230
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
231
|
+
units.stop();
|
|
225
232
|
workPanel?.dispose();
|
|
226
233
|
generation++;
|
|
227
234
|
workAbort?.abort();
|
|
@@ -239,7 +246,10 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
239
246
|
});
|
|
240
247
|
// A delivered message is in the transcript once Pi ends it: only then is it acknowledged.
|
|
241
248
|
pi.on("message_end", async event => { await inbox.ended(event.message); });
|
|
242
|
-
pi.on("agent_settled", () => {
|
|
249
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
250
|
+
inbox.settled();
|
|
251
|
+
try { await units.settled(ctx); } catch (error) { ctx.ui.notify(`Dogsled unit acknowledgement pending: ${String(error)}`, "warning"); }
|
|
252
|
+
});
|
|
243
253
|
pi.on("agent_before_settle", async event => {
|
|
244
254
|
if (!owner || event.outcome !== "completed" || !event.context.canContinue || event.continue) return;
|
|
245
255
|
// The Rust gate owns selection and durable deduplication across all harnesses.
|
|
@@ -251,7 +261,9 @@ export default function dogsled(pi: ExtensionAPI) {
|
|
|
251
261
|
continue: true,
|
|
252
262
|
};
|
|
253
263
|
});
|
|
254
|
-
pi.on("before_agent_start", async (event) => {
|
|
264
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
265
|
+
if (!units.guard(ctx)) return;
|
|
266
|
+
try { await units.ready(ctx); } catch (error) { ctx.ui.notify(`Dogsled preparation recovery pending: ${String(error)}`, "warning"); }
|
|
255
267
|
// Context is optional, including when the binary is absent or fails.
|
|
256
268
|
try {
|
|
257
269
|
const result = await pi.exec(binary, ["hook", "context", "--harness", "pi"], { timeout: 6_000 });
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolCallEventResult, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { acquire, execution, prepared, record, reference, repositoryIdentity, sameExecution, sameRef, unitInput, unwrap, type Execution, type Prepared, type UnitRef } from "./unit-protocol.ts";
|
|
3
|
+
import { UnitWorker, type UnitExec } from "./unit-worker.ts";
|
|
4
|
+
|
|
5
|
+
interface Invocation { origin: UnitRef; execution?: Execution; cwd: string; prepared?: Prepared; ended: boolean }
|
|
6
|
+
const SPAWN = "mcp__dogsled__spawn";
|
|
7
|
+
|
|
8
|
+
/** Execution bookkeeping stays in Dogsled; the task provider owns all task state. */
|
|
9
|
+
export class UnitBridge {
|
|
10
|
+
private readonly calls = new Map<string, Invocation>();
|
|
11
|
+
private epoch = 0;
|
|
12
|
+
private draining: Promise<void> | undefined;
|
|
13
|
+
private recovery: Promise<void> | undefined;
|
|
14
|
+
private recovered = false;
|
|
15
|
+
private readonly worker: UnitWorker;
|
|
16
|
+
private readonly exec: UnitExec;
|
|
17
|
+
private readonly pi: Pick<ExtensionAPI, "events" | "exec" | "getFlag" | "registerFlag">;
|
|
18
|
+
constructor(pi: Pick<ExtensionAPI, "events" | "exec" | "getFlag" | "registerFlag">, binary: string) {
|
|
19
|
+
this.pi = pi;
|
|
20
|
+
this.exec = async (args, cwd) => {
|
|
21
|
+
const result = await pi.exec(binary, args, cwd === undefined ? undefined : { cwd });
|
|
22
|
+
if (result.code !== 0) throw new Error(`dogsled ${args.slice(0, 2).join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || result.code}`);
|
|
23
|
+
return JSON.parse(result.stdout);
|
|
24
|
+
};
|
|
25
|
+
this.worker = new UnitWorker(pi, this.exec);
|
|
26
|
+
}
|
|
27
|
+
start(reason: SessionStartEvent["reason"] = "startup"): void { this.epoch++; this.recovered = false; this.worker.start(reason); }
|
|
28
|
+
stop(): void { this.epoch++; }
|
|
29
|
+
guard(ctx: ExtensionContext): boolean { return this.worker.guard(ctx); }
|
|
30
|
+
|
|
31
|
+
/** Startup handlers have completed before this boundary; no registration-order race. */
|
|
32
|
+
ready(ctx: ExtensionContext): Promise<void> {
|
|
33
|
+
if (this.recovered || !acquire(this.pi.events)) return Promise.resolve();
|
|
34
|
+
if (this.recovery) return this.recovery;
|
|
35
|
+
const epoch = this.epoch;
|
|
36
|
+
this.recovery = this.resume(ctx).then(() => { if (epoch === this.epoch) this.recovered = true; }).finally(() => { this.recovery = undefined; });
|
|
37
|
+
return this.recovery;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A completed runtime startup establishes that untracked prior invocations cannot execute here. */
|
|
41
|
+
async resume(ctx: ExtensionContext): Promise<void> {
|
|
42
|
+
if (!acquire(this.pi.events)) return;
|
|
43
|
+
const epoch = this.epoch, session = ctx.sessionManager.getSessionId();
|
|
44
|
+
const pending = await this.exec(["unit", "prepared"], ctx.cwd);
|
|
45
|
+
if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: prepared listing returned no list");
|
|
46
|
+
for (const item of pending) {
|
|
47
|
+
this.current(epoch, session, ctx);
|
|
48
|
+
if (!record(item) || !execution(item.execution) || !reference(item.origin) || typeof item.toolCallId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid preparation identity");
|
|
49
|
+
if (item.origin.sessionId !== session) continue;
|
|
50
|
+
const executionRef = item.execution, origin = item.origin;
|
|
51
|
+
if ([...this.calls.values()].some(call => !call.ended && sameRef(call.origin, origin))) continue;
|
|
52
|
+
await this.exec(["unit", "abandon-prepared", "--input", JSON.stringify({ execution: executionRef })], ctx.cwd);
|
|
53
|
+
}
|
|
54
|
+
await this.drain(ctx);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async call(event: ToolCallEvent, ctx: ExtensionContext): Promise<ToolCallEventResult | undefined> {
|
|
58
|
+
if (event.toolName !== SPAWN) return;
|
|
59
|
+
const input: unknown = event.input;
|
|
60
|
+
if (!record(input)) return { block: true, reason: "REQUEST_CONFLICT: spawn input must be an object" };
|
|
61
|
+
try {
|
|
62
|
+
if ("preparedUnit" in input) {
|
|
63
|
+
const owned = this.calls.get(event.toolCallId)?.prepared;
|
|
64
|
+
if (!owned || Object.keys(input).length !== 1 || JSON.stringify(owned) !== JSON.stringify(input.preparedUnit)) {
|
|
65
|
+
throw new Error("REQUEST_CONFLICT: preparedUnit is reserved for this invocation's mechanical handoff");
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!("unit" in input)) return;
|
|
70
|
+
const request = unitInput(input), epoch = this.epoch, session = ctx.sessionManager.getSessionId();
|
|
71
|
+
await this.ready(ctx);
|
|
72
|
+
this.current(epoch, session, ctx);
|
|
73
|
+
const provider = acquire(this.pi.events);
|
|
74
|
+
if (!provider) throw new Error("PROVIDER_UNAVAILABLE: pi-todo capability is unavailable or ambiguous");
|
|
75
|
+
const contract = unwrap(provider.inspect(request.id));
|
|
76
|
+
if (!record(contract) || !reference(contract.ref) || !record(contract.scope) || typeof contract.scope.repositoryId !== "string") {
|
|
77
|
+
throw new Error("PROVIDER_UNAVAILABLE: invalid inspected contract");
|
|
78
|
+
}
|
|
79
|
+
if (contract.ref.sessionId !== session) throw new Error("STALE_ORIGIN: task belongs to another session");
|
|
80
|
+
const origin = await this.pi.exec("git", ["remote", "get-url", "origin"], { cwd: ctx.cwd });
|
|
81
|
+
if (origin.code !== 0 || repositoryIdentity(origin.stdout) !== contract.scope.repositoryId) throw new Error("REQUEST_CONFLICT: checkout origin differs from repositoryId");
|
|
82
|
+
this.current(epoch, session, ctx);
|
|
83
|
+
// Retain the origin before I/O: prepare may persist while its response is lost.
|
|
84
|
+
const invocation: Invocation = { origin: contract.ref, cwd: ctx.cwd, ended: false };
|
|
85
|
+
this.calls.set(event.toolCallId, invocation);
|
|
86
|
+
const result = await this.exec(["unit", "prepare", "--input", JSON.stringify({ origin: contract.ref, placement: request.placement }), "--tool-call-id", event.toolCallId], ctx.cwd);
|
|
87
|
+
if (!execution(result)) throw new Error("LAUNCH_UNRESOLVED: prepare returned invalid execution");
|
|
88
|
+
invocation.execution = result;
|
|
89
|
+
this.current(epoch, session, ctx);
|
|
90
|
+
// Reacquire after I/O so shutdown and ownership changes cannot revive old authority.
|
|
91
|
+
const current = acquire(this.pi.events);
|
|
92
|
+
if (!current) throw new Error("PROVIDER_UNAVAILABLE: task provider disappeared before claim");
|
|
93
|
+
const claim = unwrap(current.claim({ expected: contract.ref, execution: result }));
|
|
94
|
+
const enriched = record(claim) ? { execution: result, binding: claim.binding, exported: claim.exported } : null;
|
|
95
|
+
if (!prepared(enriched) || !sameRef(enriched.binding.contract, contract.ref) || !sameExecution(enriched.execution, result)) throw new Error("PROVIDER_UNAVAILABLE: claim returned mismatched export");
|
|
96
|
+
invocation.prepared = enriched;
|
|
97
|
+
for (const key of Object.keys(input)) delete input[key];
|
|
98
|
+
input.preparedUnit = enriched;
|
|
99
|
+
return;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// This hook never committed. Its own failure proves this invocation did not launch.
|
|
102
|
+
const invocation = this.calls.get(event.toolCallId);
|
|
103
|
+
if (invocation?.execution) { invocation.ended = true; await this.recover(event.toolCallId, invocation, ctx); }
|
|
104
|
+
return { block: true, reason: String(error) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private current(epoch: number, session: string, ctx: ExtensionContext): void {
|
|
109
|
+
if (epoch !== this.epoch || session !== ctx.sessionManager.getSessionId() || ctx.signal?.aborted) throw new Error("STALE_ORIGIN: session changed or invocation aborted during handoff");
|
|
110
|
+
}
|
|
111
|
+
async ended(id: string, ctx: ExtensionContext): Promise<void> {
|
|
112
|
+
const invocation = this.calls.get(id);
|
|
113
|
+
if (!invocation) return;
|
|
114
|
+
invocation.ended = true;
|
|
115
|
+
await this.recover(id, invocation, ctx);
|
|
116
|
+
}
|
|
117
|
+
private async recover(id: string, invocation: Invocation, ctx: ExtensionContext): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
if (!invocation.ended) return;
|
|
120
|
+
if ([...this.calls.values()].some(other => other !== invocation && !other.ended && sameRef(other.origin, invocation.origin))) return;
|
|
121
|
+
if (!invocation.execution) {
|
|
122
|
+
const pending = await this.exec(["unit", "prepared"], invocation.cwd);
|
|
123
|
+
if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: invalid prepared listing");
|
|
124
|
+
for (const item of pending) {
|
|
125
|
+
if (!record(item) || !execution(item.execution) || !reference(item.origin) || typeof item.toolCallId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid preparation identity");
|
|
126
|
+
// Prepare deduplicates by full origin and retains the first invocation ID on retries.
|
|
127
|
+
if (sameRef(item.origin, invocation.origin)) invocation.execution = item.execution;
|
|
128
|
+
}
|
|
129
|
+
if (!invocation.execution) { this.calls.delete(id); await this.drain(ctx); return; }
|
|
130
|
+
}
|
|
131
|
+
const state = await this.exec(["unit", "observe", "--input", JSON.stringify({ execution: invocation.execution })], invocation.cwd);
|
|
132
|
+
if (!record(state) || !execution(state.execution) || !sameExecution(state.execution, invocation.execution)) throw new Error("LAUNCH_UNRESOLVED: invalid observation");
|
|
133
|
+
if (state.phase === "prepared" && invocation.ended) {
|
|
134
|
+
await this.exec(["unit", "abandon-prepared", "--input", JSON.stringify({ execution: invocation.execution })], invocation.cwd);
|
|
135
|
+
} else if (state.phase === "prepared") return;
|
|
136
|
+
this.calls.delete(id);
|
|
137
|
+
await this.drain(ctx);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// Unknown outcome keeps the durable claim and in-memory recovery intent.
|
|
140
|
+
ctx.ui.notify(`Dogsled unit recovery pending: ${String(error)}`, "warning");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Provider settlement acknowledgements are independent of inbox text acknowledgements. */
|
|
145
|
+
drain(ctx: ExtensionContext): Promise<void> {
|
|
146
|
+
if (this.draining) return this.draining;
|
|
147
|
+
const epoch = this.epoch;
|
|
148
|
+
this.draining = (async () => {
|
|
149
|
+
if (!acquire(this.pi.events)) return;
|
|
150
|
+
const pending = await this.exec(["unit", "settlements"], ctx.cwd);
|
|
151
|
+
if (!Array.isArray(pending)) throw new Error("LAUNCH_UNRESOLVED: settlements returned no list");
|
|
152
|
+
for (const event of pending) {
|
|
153
|
+
if (epoch !== this.epoch) return;
|
|
154
|
+
if (!record(event) || typeof event.eventId !== "string") throw new Error("LAUNCH_UNRESOLVED: invalid settlement identity");
|
|
155
|
+
const owner = record(event.binding) ? event.binding.execution : event.execution;
|
|
156
|
+
if (!execution(owner)) throw new Error("LAUNCH_UNRESOLVED: missing settlement execution");
|
|
157
|
+
const provider = acquire(this.pi.events);
|
|
158
|
+
if (!provider) return;
|
|
159
|
+
const result = provider.settle(event);
|
|
160
|
+
if (!result.ok) continue; // Wrong branch or revision stays pending in Dogsled.
|
|
161
|
+
if (!record(result.value) || typeof result.value.applied !== "boolean") throw new Error("PROVIDER_UNAVAILABLE: invalid settlement acknowledgement");
|
|
162
|
+
await this.exec(["unit", "ack-settlement", "--input", JSON.stringify({ execution: owner, eventId: event.eventId })], ctx.cwd);
|
|
163
|
+
}
|
|
164
|
+
})().finally(() => { this.draining = undefined; });
|
|
165
|
+
return this.draining;
|
|
166
|
+
}
|
|
167
|
+
async settled(ctx: ExtensionContext): Promise<void> {
|
|
168
|
+
for (const [id, invocation] of this.calls) {
|
|
169
|
+
if (invocation.ended) await this.recover(id, invocation, ctx);
|
|
170
|
+
}
|
|
171
|
+
await this.worker.settled(ctx);
|
|
172
|
+
await this.drain(ctx);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface UnitRef { sessionId: string; unitId: string; revision: number; contractEntryId: string }
|
|
4
|
+
export interface Execution { executor: string; ownerId: string; requestId: string }
|
|
5
|
+
export interface Binding { contract: UnitRef; execution: Execution; exportDigest: string }
|
|
6
|
+
export interface Exported { version: number; provider: string; contract: { ref: UnitRef; scope: { repositoryId: string } }; digest: string; [key: string]: unknown }
|
|
7
|
+
export interface Prepared { execution: Execution; binding: Binding; exported: Exported }
|
|
8
|
+
export interface Receipt { version: number; execution: Execution; unitRef: UnitRef; digest: string; localSessionId: string; seal: { entryId: string; disposition: "completed" | "abandoned" } | null }
|
|
9
|
+
export type Result = { ok: true; value: unknown } | { ok: false; error: { code: string; message: string } };
|
|
10
|
+
export interface TaskProvider {
|
|
11
|
+
inspect(id: string): Result;
|
|
12
|
+
claim(input: { expected: UnitRef; execution: Execution }): Result;
|
|
13
|
+
settle(input: unknown): Result;
|
|
14
|
+
imported(): Result;
|
|
15
|
+
}
|
|
16
|
+
export type Placement = { host?: string; branch?: string; base?: string };
|
|
17
|
+
export function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
18
|
+
function text(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; }
|
|
19
|
+
export function reference(value: unknown): value is UnitRef {
|
|
20
|
+
return record(value) && text(value.sessionId) && text(value.unitId) && Number.isSafeInteger(value.revision) && Number(value.revision) > 0 && text(value.contractEntryId);
|
|
21
|
+
}
|
|
22
|
+
export function execution(value: unknown): value is Execution {
|
|
23
|
+
return record(value) && value.executor === "dogsled" && text(value.ownerId) && text(value.requestId);
|
|
24
|
+
}
|
|
25
|
+
export function sameRef(a: UnitRef, b: UnitRef): boolean {
|
|
26
|
+
return a.sessionId === b.sessionId && a.unitId === b.unitId && a.revision === b.revision && a.contractEntryId === b.contractEntryId;
|
|
27
|
+
}
|
|
28
|
+
export function sameExecution(a: Execution, b: Execution): boolean {
|
|
29
|
+
return a.executor === b.executor && a.ownerId === b.ownerId && a.requestId === b.requestId;
|
|
30
|
+
}
|
|
31
|
+
export function binding(value: unknown): value is Binding {
|
|
32
|
+
return record(value) && reference(value.contract) && execution(value.execution) && typeof value.exportDigest === "string" && /^[a-f0-9]{64}$/.test(value.exportDigest);
|
|
33
|
+
}
|
|
34
|
+
export function exported(value: unknown): value is Exported {
|
|
35
|
+
return record(value) && value.version === 1 && value.provider === "pi-todo" && record(value.contract)
|
|
36
|
+
&& reference(value.contract.ref) && record(value.contract.scope) && text(value.contract.scope.repositoryId)
|
|
37
|
+
&& typeof value.digest === "string" && /^[a-f0-9]{64}$/.test(value.digest);
|
|
38
|
+
}
|
|
39
|
+
export function prepared(value: unknown): value is Prepared {
|
|
40
|
+
return record(value) && execution(value.execution) && binding(value.binding) && exported(value.exported)
|
|
41
|
+
&& sameExecution(value.execution, value.binding.execution) && sameRef(value.binding.contract, value.exported.contract.ref)
|
|
42
|
+
&& value.binding.exportDigest === value.exported.digest;
|
|
43
|
+
}
|
|
44
|
+
export function receipt(value: unknown): value is Receipt {
|
|
45
|
+
return record(value) && value.version === 1 && execution(value.execution) && reference(value.unitRef)
|
|
46
|
+
&& typeof value.digest === "string" && /^[a-f0-9]{64}$/.test(value.digest) && text(value.localSessionId)
|
|
47
|
+
&& (value.seal === null || (record(value.seal) && text(value.seal.entryId) && ["completed", "abandoned"].includes(String(value.seal.disposition))));
|
|
48
|
+
}
|
|
49
|
+
function provider(value: unknown): value is TaskProvider {
|
|
50
|
+
return record(value) && ["inspect", "claim", "settle", "imported"].every(key => typeof value[key] === "function");
|
|
51
|
+
}
|
|
52
|
+
export function acquire(events: ExtensionAPI["events"]): TaskProvider | undefined {
|
|
53
|
+
const replies: unknown[] = [];
|
|
54
|
+
events.emit("oisin.tasks.acquire.v1", { version: 1, reply: (value: unknown) => { replies.push(value); } });
|
|
55
|
+
if (replies.length !== 1 || !provider(replies[0])) return undefined;
|
|
56
|
+
return replies[0];
|
|
57
|
+
}
|
|
58
|
+
export function unwrap(result: Result): unknown {
|
|
59
|
+
if (!record(result) || typeof result.ok !== "boolean") throw new Error("PROVIDER_UNAVAILABLE: invalid capability result");
|
|
60
|
+
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
61
|
+
return result.value;
|
|
62
|
+
}
|
|
63
|
+
export function unitInput(input: Record<string, unknown>): { id: string; placement: Placement } {
|
|
64
|
+
if (Object.keys(input).some(key => !["unit", "host", "branch", "base"].includes(key)) || !record(input.unit)
|
|
65
|
+
|| Object.keys(input.unit).length !== 1 || !text(input.unit.id)) throw new Error("REQUEST_CONFLICT: unit spawn cannot include a brief, profile, cwd, or prepared envelope");
|
|
66
|
+
const placement: Placement = {};
|
|
67
|
+
for (const key of ["host", "branch", "base"] as const) {
|
|
68
|
+
if (input[key] === undefined) continue;
|
|
69
|
+
if (!text(input[key])) throw new Error(`REQUEST_CONFLICT: ${key} must be a nonempty string`);
|
|
70
|
+
placement[key] = input[key];
|
|
71
|
+
}
|
|
72
|
+
if (placement.base && !placement.branch) throw new Error("REQUEST_CONFLICT: base requires branch");
|
|
73
|
+
return { id: input.unit.id, placement };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clone transport is not repository identity. Keep path case; do not accept local paths. */
|
|
77
|
+
export function repositoryIdentity(origin: string): string {
|
|
78
|
+
const value = origin.trim();
|
|
79
|
+
const scp = value.includes("://") ? null : /^(?:[^/@:\s]+@)?(?<host>[^/:\s]+):(?<path>[^\s]+)$/.exec(value);
|
|
80
|
+
const url = new URL(scp?.groups ? `ssh://${scp.groups["host"]}/${scp.groups["path"]}` : value);
|
|
81
|
+
const path = url.pathname.replace(/^\//, "").replace(/\/$/, "").replace(/\.git$/, "");
|
|
82
|
+
if (!["ssh:", "https:", "http:", "git:"].includes(url.protocol) || !url.hostname || !path || url.search || url.hash
|
|
83
|
+
|| path.split("/").some(part => !part || part === "." || part === "..")) throw new Error("REQUEST_CONFLICT: origin must identify a remote repository host/path");
|
|
84
|
+
return `${url.hostname.toLowerCase()}/${path}`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { acquire, prepared, receipt, record, sameExecution, sameRef, unwrap, type Prepared, type Receipt } from "./unit-protocol.ts";
|
|
4
|
+
|
|
5
|
+
/** Read transport correlation, not task semantics; pi-todo validates the actual contract. */
|
|
6
|
+
function seedFile(path: string): Prepared {
|
|
7
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
8
|
+
try {
|
|
9
|
+
const stat = fstatSync(fd);
|
|
10
|
+
if (!stat.isFile() || stat.size > 1_048_576) throw new Error("invalid seed artifact");
|
|
11
|
+
const bytes = readFileSync(fd);
|
|
12
|
+
if (bytes.length > 1_048_576) throw new Error("seed artifact too large");
|
|
13
|
+
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
14
|
+
if (!record(value) || value.version !== 1 || !prepared(value)) throw new Error("invalid seed correlation");
|
|
15
|
+
return value;
|
|
16
|
+
} finally { closeSync(fd); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UnitExec = (args: string[], cwd?: string) => Promise<unknown>;
|
|
20
|
+
|
|
21
|
+
/** The loaded guard enforces fresh-session startup, not resume-time compaction. */
|
|
22
|
+
export class UnitWorker {
|
|
23
|
+
private seed: Prepared | undefined;
|
|
24
|
+
private failure: string | undefined;
|
|
25
|
+
private managed = false;
|
|
26
|
+
private reason: SessionStartEvent["reason"] = "startup";
|
|
27
|
+
private readonly pi: Pick<ExtensionAPI, "events" | "getFlag" | "registerFlag">;
|
|
28
|
+
private readonly exec: UnitExec;
|
|
29
|
+
constructor(pi: Pick<ExtensionAPI, "events" | "getFlag" | "registerFlag">, exec: UnitExec) {
|
|
30
|
+
this.pi = pi; this.exec = exec;
|
|
31
|
+
// Dogsled alone owns the launch flag and supplies its path over the event bus.
|
|
32
|
+
// Register it even without pi-todo so the loaded guard can reject missing import.
|
|
33
|
+
pi.registerFlag("todo-unit-file", { type: "string", description: "Executor-owned worker task seed" });
|
|
34
|
+
// Register during extension loading, before any session_start import handler.
|
|
35
|
+
pi.events.on("oisin.tasks.seed-source.v1", input => {
|
|
36
|
+
if (!record(input) || input.version !== 1 || typeof input.reply !== "function") return;
|
|
37
|
+
input.reply({ version: 1, path: pi.getFlag("todo-unit-file") ?? null });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
start(reason: SessionStartEvent["reason"] = "startup"): void {
|
|
41
|
+
this.reason = reason;
|
|
42
|
+
this.seed = undefined;
|
|
43
|
+
this.failure = undefined;
|
|
44
|
+
const path = this.pi.getFlag("todo-unit-file");
|
|
45
|
+
this.managed = path !== undefined;
|
|
46
|
+
if (!this.managed) return;
|
|
47
|
+
try {
|
|
48
|
+
if (typeof path !== "string" || !path) throw new Error("missing seed path");
|
|
49
|
+
this.seed = seedFile(path);
|
|
50
|
+
} catch (error) { this.failure = String(error); }
|
|
51
|
+
}
|
|
52
|
+
private imported(ctx: ExtensionContext): Receipt | null {
|
|
53
|
+
const provider = acquire(this.pi.events);
|
|
54
|
+
if (!provider) {
|
|
55
|
+
if (this.managed) throw new Error("task provider unavailable");
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const value = unwrap(provider.imported());
|
|
59
|
+
if (value === null && !this.managed) return null;
|
|
60
|
+
if (!receipt(value) || value.localSessionId !== ctx.sessionManager.getSessionId()
|
|
61
|
+
|| value.localSessionId !== value.execution.requestId) throw new Error("missing or mismatched import acknowledgement");
|
|
62
|
+
const recorded = this.reason === "reload" || this.reason === "resume"
|
|
63
|
+
|| ctx.sessionManager.getEntries().some(entry => entry.type === "message");
|
|
64
|
+
if (!recorded) {
|
|
65
|
+
if (!this.seed || this.failure) throw new Error(this.failure ?? "missing seed");
|
|
66
|
+
if (!sameExecution(value.execution, this.seed.execution) || !sameRef(value.unitRef, this.seed.binding.contract)
|
|
67
|
+
|| value.digest !== this.seed.binding.exportDigest) throw new Error("mismatched seed acknowledgement");
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
/** Called synchronously before any other before-agent-start await. */
|
|
72
|
+
guard(ctx: ExtensionContext): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const imported = this.imported(ctx);
|
|
75
|
+
if (imported?.seal) throw new Error("worker execution is sealed");
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
ctx.abort();
|
|
79
|
+
ctx.ui.notify(`IMPORT_FAILED: ${String(error)}`, "error");
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async settled(ctx: ExtensionContext): Promise<void> {
|
|
84
|
+
const imported = this.imported(ctx);
|
|
85
|
+
if (!imported?.seal) return;
|
|
86
|
+
const leaf = ctx.sessionManager.getLeafId();
|
|
87
|
+
if (!leaf) throw new Error("IMPORT_FAILED: sealed session has no final leaf");
|
|
88
|
+
await this.exec(["unit", "terminal-ack", "--input", JSON.stringify({
|
|
89
|
+
execution: imported.execution, unitRef: imported.unitRef, digest: imported.digest,
|
|
90
|
+
sealEntryId: imported.seal.entryId, sessionLeafId: leaf,
|
|
91
|
+
})], ctx.cwd);
|
|
92
|
+
}
|
|
93
|
+
}
|