pi-tmux-session-map 0.4.0 → 0.5.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 +33 -4
- package/package.json +1 -1
- package/src/continuation-executor.ts +323 -0
- package/src/domain/atomic-write.ts +23 -1
- package/src/domain/continuation.ts +374 -0
- package/src/domain/pane-ownership.ts +23 -0
- package/src/domain/signal-evidence.ts +56 -0
- package/src/domain/status.ts +54 -0
- package/src/index.ts +198 -20
package/README.md
CHANGED
|
@@ -30,7 +30,33 @@ For tmux it writes the exact-resume mapping and status sidecar. For Zellij it wr
|
|
|
30
30
|
~/.config/tws-zellij/pi-status/<sanitized-pane-key>-<sha256-12>.json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
The `.session` files contain schema-3 JSON with pane key, pane ID, tmux server PID, exact Pi session path and ID, cwd, and session name. They are written only
|
|
33
|
+
The `.session` files contain schema-3 JSON with pane key, pane ID, tmux server PID, exact Pi session path and ID, cwd, and session name. They are written only by an interactive Pi TUI with a real stdin TTY, never by a headless `pi-subagents` child that merely inherited the parent's tmux environment. Publication also requires that the JSONL exists and its session header matches Pi's current session ID. Writes occur on Pi lifecycle events and periodic reconciliation. A replacement key and pane-ID alias are published atomically before stale names are removed, so interruption can leave duplicates but never a mapping gap.
|
|
34
|
+
|
|
35
|
+
The status `.json` also publishes `session_id`, `pi_pid` and `tmux_server_pid`.
|
|
36
|
+
This live identity comes directly from Pi at session start, even before its JSONL
|
|
37
|
+
exists. Consumers must match the foreground Pi PID, tmux server and pane, reject
|
|
38
|
+
shutdown/stale status, and revalidate before copying. The session ID is therefore
|
|
39
|
+
available before the first message; a resume command still requires a matching
|
|
40
|
+
on-disk header. The durable `.session` mappings retain their persistence checks.
|
|
41
|
+
|
|
42
|
+
For tmux-recovery's auto-continue decision the status also carries recovery evidence. `run_anchor` (`{ leaf_id, started_at_ms }`) is set at the `agent_start` that begins a run: `leaf_id` is the session leaf before the run's prompt entry, captured synchronously because Pi persists the prompt after `agent_start`. Retries keep the anchor; final states and `idle` clear it. On `shutdown`, `shutdown_cause` is `signal` when the process received SIGHUP or SIGTERM (closed terminal, dead tmux server, reboot), `quit` for an interactive quit, `reload`, or `replace` for `/new`, `/resume` and fork. Only the interactive pane owner installs the signal listener, and the cause is resolved when the queued write builds its payload, after the signal dispatch has finished. A graceful signal shutdown can still leave `working` or `retrying` when Pi exits on a dead terminal before the write lands.
|
|
43
|
+
|
|
44
|
+
### Auto-continue executor
|
|
45
|
+
|
|
46
|
+
When tmux-recovery restores a Pi that was interrupted inside a run, its resume wrapper sets `PI_TMUX_CONTINUE_PLAN` to a plan file in the recovery ledger. Only that restored process runs the executor, once, at its `startup`, and only as the interactive pane owner for the planned session. The executor waits for the restore acknowledgement (`resumed`; a mismatch skips) and for the plan's slot, then checks the final conditions in one section under the ledger's dispatch lock: plan still released and listed in `ready.json`, mode `on`, no hold file, same session, pane and last message entry, Pi idle without pending messages, empty editor, no open dialog, no live `pi-session-watchdog` for the session. It then claims the plan with an exclusive create (`<key>.claim`, never removed) and sends one visible `tmux-recovery-continue` message with `triggerTurn`. The German text names the stop time, the elapsed time and open tool calls and asks Pi to check the actual state first; its `continuationId` matches the message details.
|
|
47
|
+
|
|
48
|
+
Any real keystroke (`ctx.ui.onTerminalInput`), interactive or RPC input, extension dialog or other run before the dispatch writes a durable veto (`<key>.skip`, first writer wins). A session end before the claim writes a `process-end` skip. After the dispatch, `before_agent_start` with the matching `continuationId` writes the outcome `admitted`, and the turn's `agent_settled` writes its final state; without admission within 120 s the outcome is `uncertain`. Nothing is ever re-sent.
|
|
49
|
+
|
|
50
|
+
A `<key>.skip` that another program wrote before the claim is a veto too: TWS writes `{"class": "veto", "reason": "skipped-in-tws", …}` with an exclusive create when you skip a planned continuation. The executor checks for it at least once a second while it waits for the slot or the spacing, and again inside the dispatch section; it then stops without a claim and keeps the file as written. A file it cannot read or parse also counts as a veto. A skip written after the claim has no effect, so a writer should check `<key>.claim` after its own create to know whether it came too late.
|
|
51
|
+
|
|
52
|
+
The status sidecar shows the executor in `continue`, from its start on:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
"continue": {"state": "planned", "reason": null, "slot_ms": 1794000014000, "deadline_ms": 1794000614000,
|
|
56
|
+
"key": "<64 hex>", "ledger_dir": "/Users/…/resurrect/pi-continue/ledger"}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`state` is `planned`, `dispatched`, `admitted`, the turn's outcome (`done`, `failed`, `cancelled`, …), `uncertain`, `skipped` or `stopped`; `reason` is set for `skipped` and `stopped` (for example `user-active`, `skipped-in-tws`, `claimed`). `slot_ms` and `deadline_ms` come from the released plan and stay `null` until the release.
|
|
34
60
|
|
|
35
61
|
The `.json` file contains the multiplexer backend, tws work state (`idle`, `working`, `retrying`, `done`, `cancelled`, `incomplete`, `failed`, `shutdown`), and pane/session metadata. A technical-error `agent_end` is provisional `retrying`; aborts and token-limit responses become `cancelled`/`incomplete` immediately. `agent_settled` confirms the final outcome (`failed` if recovery did not succeed). The extension touches the matching `agent.trigger` file after status writes so that edition refreshes within one poll tick.
|
|
36
62
|
|
|
@@ -99,14 +125,14 @@ set -g @resurrect-processes '"~pi-coding-agent/dist/cli.js->pi-tmux-resume"'
|
|
|
99
125
|
|
|
100
126
|
## Behavior
|
|
101
127
|
|
|
102
|
-
The exact session mapping
|
|
128
|
+
The exact session mapping and pane-scoped tws status are written only when Pi is an interactive TUI with a real stdin TTY and is not marked as a `PI_SUBAGENT_CHILD`. Tmux additionally requires `$TMUX` and `$TMUX_PANE`; Zellij requires `$ZELLIJ`, `$ZELLIJ_SESSION_NAME`, and `$ZELLIJ_PANE_ID`. This ownership gate prevents invisible headless children from replacing the visible parent's resume identity or work status. When an eligible interactive session has no Pi session path, the status `session_file` is `null`. Pi can also supply a planned path before the file exists; that path alone is not proof of resumability. The live `session_id` remains available in either case. Final failure-like outcomes are delayed until `agent_settled`, so automatic retries or compaction recovery do not appear as permanent failures.
|
|
103
129
|
|
|
104
130
|
If the recorded session file no longer exists, the wrapper falls back to `pi --continue` for the pane's working directory.
|
|
105
131
|
|
|
106
132
|
## Caveats
|
|
107
133
|
|
|
108
134
|
- Pane IDs are scoped to one tmux server and may be reused after restart. Schema 3 therefore includes the tmux server PID and consumers require both values before using a live alias.
|
|
109
|
-
- A Pi process
|
|
135
|
+
- A Pi process without a valid on-disk JSONL has a live identity but no durable resume mapping. The extension waits for persistence before publishing a resume alias; it never creates session history merely to make a command available.
|
|
110
136
|
|
|
111
137
|
## License
|
|
112
138
|
|
|
@@ -121,9 +147,12 @@ Optional environment variables for hardening or local integration:
|
|
|
121
147
|
- `PI_TMUX_SESSION_MAP_TWS_STATUS_DIR` — override only the tws status directory.
|
|
122
148
|
- `PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE` — override the tws trigger file.
|
|
123
149
|
- `PI_TMUX_SESSION_MAP_TMUX_BIN` — override the `tmux` binary path.
|
|
150
|
+
- `PI_TMUX_SESSION_MAP_RESTORE_PANE_TIMEOUTS_MS` — comma-separated `tmux display-message` timeouts while a restore acknowledgement is pending; default `1000,2000,4000,8000,16000`.
|
|
124
151
|
- `PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS` — periodic mapping reconciliation interval; minimum 10 seconds, default 60 seconds, deterministically jittered per pane.
|
|
125
152
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_BIN` — override the `zellij` binary path.
|
|
126
153
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR` — override the Zellij TWS status directory.
|
|
127
154
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE` — override the Zellij TWS trigger file.
|
|
128
155
|
|
|
129
|
-
|
|
156
|
+
A restore acknowledgement (`PI_TMUX_RESTORE_*` environment from `pi-tmux-resume`) is written at most once per token: the first writer wins, so a later `/new` or `/reload` cannot replace the proof of what the restore resumed. A restore keeps the tmux server busy, so while the acknowledgement is pending the pane lookup is retried with growing timeouts; if every attempt fails, the next lifecycle event or reconciliation writes it.
|
|
157
|
+
|
|
158
|
+
Lifecycle sidecar updates are serialized in-process, mapping and status files are written atomically, and `tmux display-message` is bounded by a short timeout so Pi lifecycle hooks do not hang indefinitely. A refresh that times out keeps the last known pane of the process, so a busy tmux never drops a status write such as `working` at `agent_start`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Timer, veto latch and admission tracking around src/domain/continuation.ts.
|
|
2
|
+
// index.ts forwards the Pi events; this class never registers handlers itself.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CONTINUE_CUSTOM_TYPE, acquireDispatchLock, continuationMessage, createExclusive, decide, existingSkipReason, lastDispatchMs,
|
|
10
|
+
lastMessageEntryId, readMode, readPlan, readyState, releaseDispatchLock, testCheckpoint, watchdogAlive, writeAtomicSync,
|
|
11
|
+
writeOutcome, writeSkip, type ContinuePlan, type LiveFacts,
|
|
12
|
+
} from "./domain/continuation.ts";
|
|
13
|
+
import type { ContinueStatus } from "./domain/status.ts";
|
|
14
|
+
|
|
15
|
+
/** Also the longest wait before the slot, so an outside veto shows within a second. */
|
|
16
|
+
const POLL_MS = 1000;
|
|
17
|
+
const LOCK_RETRY_MS = 1000;
|
|
18
|
+
const ADMISSION_TIMEOUT_MS = 120_000;
|
|
19
|
+
/** Without a ready.json the restore never released this attempt. */
|
|
20
|
+
const READY_WAIT_LIMIT_MS = 30 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
export type LiveSource = {
|
|
23
|
+
ackResumed(): boolean | undefined;
|
|
24
|
+
paneKey(): string | null;
|
|
25
|
+
sessionId(): string;
|
|
26
|
+
sessionFile(): string | null;
|
|
27
|
+
branch(): readonly { id?: string; type?: string; message?: { role?: string } }[];
|
|
28
|
+
idle(): boolean;
|
|
29
|
+
pendingMessages(): boolean;
|
|
30
|
+
editorText(): string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SendMessage = (
|
|
34
|
+
message: { customType: string; content: string; display: boolean; details: Record<string, unknown> },
|
|
35
|
+
options: { triggerTurn: boolean },
|
|
36
|
+
) => void;
|
|
37
|
+
|
|
38
|
+
export class ContinuationExecutor {
|
|
39
|
+
readonly continuationId = randomUUID();
|
|
40
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
private admissionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
private finished = false;
|
|
43
|
+
private claimed = false;
|
|
44
|
+
private admitted = false;
|
|
45
|
+
private outcomeWritten = false;
|
|
46
|
+
private userActive = false;
|
|
47
|
+
private promptDepth = 0;
|
|
48
|
+
private startedAtMs = Date.now();
|
|
49
|
+
private stateValue = "planned";
|
|
50
|
+
private slotMs: number | null;
|
|
51
|
+
private deadlineMs: number | null;
|
|
52
|
+
/** End of a spacing wait decided under the dispatch lock. */
|
|
53
|
+
private waitUntilMs = 0;
|
|
54
|
+
readonly planPath: string;
|
|
55
|
+
readonly plan: ContinuePlan;
|
|
56
|
+
private readonly live: LiveSource;
|
|
57
|
+
private readonly send: SendMessage;
|
|
58
|
+
private readonly onChange: () => void;
|
|
59
|
+
private readonly watchdogRoot: string;
|
|
60
|
+
|
|
61
|
+
private constructor(planPath: string, plan: ContinuePlan, live: LiveSource, send: SendMessage, onChange: () => void, watchdogRoot: string) {
|
|
62
|
+
this.planPath = planPath;
|
|
63
|
+
this.plan = plan;
|
|
64
|
+
this.slotMs = plan.slotMs ?? null;
|
|
65
|
+
this.deadlineMs = plan.deadlineMs ?? null;
|
|
66
|
+
this.live = live;
|
|
67
|
+
this.send = send;
|
|
68
|
+
this.onChange = onChange;
|
|
69
|
+
this.watchdogRoot = watchdogRoot;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static fromEnvironment(live: LiveSource, send: SendMessage, onChange: () => void, env = process.env): ContinuationExecutor | undefined {
|
|
73
|
+
const planPath = env.PI_TMUX_CONTINUE_PLAN;
|
|
74
|
+
if (!planPath) return undefined;
|
|
75
|
+
const plan = readPlan(planPath);
|
|
76
|
+
if (!plan || plan.sessionId !== live.sessionId()) return undefined;
|
|
77
|
+
return new ContinuationExecutor(planPath, plan, live, send, onChange, env.PI_TMUX_SESSION_WATCHDOG_ROOT ?? "/tmp");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Executor state as text: planned, dispatched, admitted, an outcome, skipped:<reason>, stopped:<reason>. */
|
|
81
|
+
state(): string {
|
|
82
|
+
return this.stateValue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The status sidecar `continue` field. */
|
|
86
|
+
status(): ContinueStatus {
|
|
87
|
+
const separator = this.stateValue.indexOf(":");
|
|
88
|
+
return {
|
|
89
|
+
state: separator === -1 ? this.stateValue : this.stateValue.slice(0, separator),
|
|
90
|
+
reason: separator === -1 ? null : this.stateValue.slice(separator + 1),
|
|
91
|
+
slot_ms: this.slotMs,
|
|
92
|
+
deadline_ms: this.deadlineMs,
|
|
93
|
+
key: this.plan.key,
|
|
94
|
+
ledger_dir: this.plan.ledgerDir,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
start(): void {
|
|
99
|
+
this.startedAtMs = Date.now();
|
|
100
|
+
// Publish `planned` (with its slot, once known) so a display can show the wait.
|
|
101
|
+
this.onChange();
|
|
102
|
+
this.schedule(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -- events forwarded by index.ts -------------------------------------------
|
|
106
|
+
|
|
107
|
+
noteUserActivity(): void {
|
|
108
|
+
if (this.claimed || this.finished) return;
|
|
109
|
+
this.userActive = true;
|
|
110
|
+
this.skip("veto", "user-active");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
notePrompt(open: boolean): void {
|
|
114
|
+
this.promptDepth = Math.max(0, this.promptDepth + (open ? 1 : -1));
|
|
115
|
+
if (open) this.noteUserActivity();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
beforeAgentStart(event: { messageType?: string; message?: { customType?: string; details?: { continuationId?: string } } }): void {
|
|
119
|
+
if (event.messageType === "custom" && event.message?.customType === CONTINUE_CUSTOM_TYPE
|
|
120
|
+
&& event.message.details?.continuationId === this.continuationId) {
|
|
121
|
+
this.admit();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Any other run before the dispatch is activity in this session.
|
|
125
|
+
if (!this.claimed && !this.finished) {
|
|
126
|
+
this.skip("veto", "busy");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
agentSettled(outcome: string): void {
|
|
131
|
+
// Only the continuation turn's own outcome; later user runs are not ours.
|
|
132
|
+
if (!this.admitted || this.outcomeWritten) return;
|
|
133
|
+
this.outcomeWritten = true;
|
|
134
|
+
writeOutcome(this.plan, outcome, this.continuationId);
|
|
135
|
+
this.setState(outcome);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
shutdown(): void {
|
|
139
|
+
this.clearTimers();
|
|
140
|
+
if (!this.claimed && !this.finished) {
|
|
141
|
+
writeSkip(this.plan, "process-end", "session-ended");
|
|
142
|
+
this.finish("skipped:session-ended");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -- internals ----------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
private setState(value: string): void {
|
|
149
|
+
this.stateValue = value;
|
|
150
|
+
this.onChange();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private finish(value: string): void {
|
|
154
|
+
this.finished = true;
|
|
155
|
+
this.clearTimers();
|
|
156
|
+
this.setState(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private skip(skipClass: "process-end" | "veto", reason: string): void {
|
|
160
|
+
writeSkip(this.plan, skipClass, reason);
|
|
161
|
+
this.finish(`skipped:${reason}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private clearTimers(): void {
|
|
165
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
166
|
+
this.timer = undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private schedule(ms: number): void {
|
|
170
|
+
this.clearTimers();
|
|
171
|
+
if (this.finished) return;
|
|
172
|
+
this.timer = setTimeout(() => {
|
|
173
|
+
this.timer = undefined;
|
|
174
|
+
try {
|
|
175
|
+
this.tick();
|
|
176
|
+
} catch {
|
|
177
|
+
// A file system error must not crash Pi; give up without dispatching.
|
|
178
|
+
if (!this.claimed) this.finish("stopped:error");
|
|
179
|
+
}
|
|
180
|
+
}, Math.max(0, ms));
|
|
181
|
+
this.timer.unref?.();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private tick(): void {
|
|
185
|
+
if (this.finished) return;
|
|
186
|
+
// A veto from outside (TWS `x`) ends the wait at once; the skip file is not ours to write.
|
|
187
|
+
const vetoReason = existingSkipReason(this.plan);
|
|
188
|
+
if (vetoReason !== null) {
|
|
189
|
+
this.finish(`skipped:${vetoReason}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
switch (readyState(this.plan)) {
|
|
194
|
+
case "waiting":
|
|
195
|
+
if (now - this.startedAtMs > READY_WAIT_LIMIT_MS) {
|
|
196
|
+
this.skip("process-end", "timeout");
|
|
197
|
+
} else {
|
|
198
|
+
this.schedule(POLL_MS);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
case "not-released":
|
|
202
|
+
this.skip("process-end", "not-released");
|
|
203
|
+
return;
|
|
204
|
+
case "released":
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
const onDisk = readPlan(this.planPath);
|
|
208
|
+
if (onDisk?.token === this.plan.token) {
|
|
209
|
+
this.noteSlot(onDisk.slotMs ?? null, onDisk.deadlineMs ?? null);
|
|
210
|
+
}
|
|
211
|
+
if (onDisk?.slotMs != null && now < onDisk.slotMs && onDisk.token === this.plan.token) {
|
|
212
|
+
this.schedule(Math.min(onDisk.slotMs - now, POLL_MS));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (now < this.waitUntilMs) {
|
|
216
|
+
this.schedule(Math.min(this.waitUntilMs - now, POLL_MS));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.dispatchSection(now);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private facts(now: number): LiveFacts {
|
|
223
|
+
return {
|
|
224
|
+
nowMs: now,
|
|
225
|
+
ackResumed: this.live.ackResumed() === true,
|
|
226
|
+
sessionId: this.live.sessionId(),
|
|
227
|
+
sessionFile: this.live.sessionFile(),
|
|
228
|
+
paneKey: this.live.paneKey(),
|
|
229
|
+
lastMessageEntryId: lastMessageEntryId(this.live.branch()),
|
|
230
|
+
idle: this.live.idle(),
|
|
231
|
+
pendingMessages: this.live.pendingMessages(),
|
|
232
|
+
editorText: this.live.editorText(),
|
|
233
|
+
userActive: this.userActive,
|
|
234
|
+
promptOpen: this.promptDepth > 0,
|
|
235
|
+
watchdogAlive: watchdogAlive(this.plan.sessionId, this.watchdogRoot),
|
|
236
|
+
mode: readMode(this.plan.configPath),
|
|
237
|
+
holdPresent: existsSync(this.plan.holdFile),
|
|
238
|
+
skipReason: existingSkipReason(this.plan),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private noteSlot(slotMs: number | null, deadlineMs: number | null): void {
|
|
243
|
+
if (slotMs === this.slotMs && deadlineMs === this.deadlineMs) return;
|
|
244
|
+
this.slotMs = slotMs;
|
|
245
|
+
this.deadlineMs = deadlineMs;
|
|
246
|
+
this.onChange();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One synchronous section: lock, E1-E7, claim, send, timestamp, unlock. */
|
|
250
|
+
private dispatchSection(now: number): void {
|
|
251
|
+
if (this.live.ackResumed() === undefined) {
|
|
252
|
+
this.schedule(POLL_MS); // the acknowledgement is still pending
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const lock = acquireDispatchLock(this.plan.ledgerDir, this.continuationId);
|
|
256
|
+
if (!lock) {
|
|
257
|
+
this.schedule(LOCK_RETRY_MS);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
testCheckpoint(this.plan.ledgerDir, "in-dispatch-lock");
|
|
262
|
+
const claimPath = join(this.plan.ledgerDir, `${this.plan.key}.claim`);
|
|
263
|
+
// The pre-check runs under the dispatch lock; the O_EXCL claim below
|
|
264
|
+
// still decides if another holder claimed in between (reclaimed lock).
|
|
265
|
+
const claimedBefore = existsSync(claimPath);
|
|
266
|
+
const verdict = decide(this.plan, readPlan(this.planPath), this.facts(now), claimedBefore, lastDispatchMs(this.plan.ledgerDir));
|
|
267
|
+
switch (verdict.kind) {
|
|
268
|
+
case "wait":
|
|
269
|
+
this.waitUntilMs = verdict.untilMs;
|
|
270
|
+
this.schedule(Math.min(verdict.untilMs - now, POLL_MS));
|
|
271
|
+
return;
|
|
272
|
+
case "stop":
|
|
273
|
+
this.finish(`stopped:${verdict.reason}`);
|
|
274
|
+
return;
|
|
275
|
+
case "skip":
|
|
276
|
+
this.skip(verdict.skipClass, verdict.reason);
|
|
277
|
+
return;
|
|
278
|
+
case "vetoed":
|
|
279
|
+
this.finish(`skipped:${verdict.reason}`);
|
|
280
|
+
return;
|
|
281
|
+
case "dispatch":
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const claim = { continuationId: this.continuationId, token: this.plan.token, key: this.plan.key, sessionId: this.plan.sessionId, pid: process.pid, claimedAtMs: now };
|
|
285
|
+
if (!createExclusive(claimPath, `${JSON.stringify(claim)}\n`)) {
|
|
286
|
+
this.finish("stopped:claimed");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.claimed = true;
|
|
290
|
+
testCheckpoint(this.plan.ledgerDir, "after-claim");
|
|
291
|
+
try {
|
|
292
|
+
this.send(
|
|
293
|
+
{ customType: CONTINUE_CUSTOM_TYPE, content: continuationMessage(this.plan, now, this.continuationId), display: true, details: { continuationId: this.continuationId } },
|
|
294
|
+
{ triggerTurn: true },
|
|
295
|
+
);
|
|
296
|
+
this.setState("dispatched");
|
|
297
|
+
this.admissionTimer = setTimeout(() => {
|
|
298
|
+
if (!this.admitted) {
|
|
299
|
+
writeOutcome(this.plan, "uncertain", this.continuationId, { reason: "no-admission" });
|
|
300
|
+
this.setState("uncertain");
|
|
301
|
+
}
|
|
302
|
+
}, ADMISSION_TIMEOUT_MS);
|
|
303
|
+
this.admissionTimer.unref?.();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
writeOutcome(this.plan, "uncertain", this.continuationId, { reason: `send failed: ${String(error)}` });
|
|
306
|
+
this.setState("uncertain");
|
|
307
|
+
}
|
|
308
|
+
writeAtomicSync(join(this.plan.ledgerDir, ".last-dispatch"), `${JSON.stringify({ atMs: now, pid: process.pid, continuationId: this.continuationId })}\n`);
|
|
309
|
+
this.finished = true;
|
|
310
|
+
} finally {
|
|
311
|
+
releaseDispatchLock(lock);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private admit(): void {
|
|
316
|
+
if (this.admitted) return;
|
|
317
|
+
this.admitted = true;
|
|
318
|
+
if (this.admissionTimer !== undefined) clearTimeout(this.admissionTimer);
|
|
319
|
+
// A late admission upgrades an earlier `uncertain` (display only; never re-sent).
|
|
320
|
+
writeOutcome(this.plan, "admitted", this.continuationId);
|
|
321
|
+
this.setState("admitted");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { link, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { mappedPaneIdentity, mappedSessionFile } from "./session-mapping.ts";
|
|
@@ -33,6 +33,28 @@ export async function writeAtomic(file: string, data: string, mode: number): Pro
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Publish `file` with its complete content only if it does not exist yet.
|
|
38
|
+
* Returns false when another writer published it first; its content is kept.
|
|
39
|
+
*/
|
|
40
|
+
export async function writeAtomicExclusive(file: string, data: string, mode: number): Promise<boolean> {
|
|
41
|
+
const tmp = nextTempPath(file);
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(tmp, data, { mode });
|
|
44
|
+
try {
|
|
45
|
+
await link(tmp, file);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await rm(tmp, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
export async function cleanupStaleTempFiles(
|
|
37
59
|
dir: string,
|
|
38
60
|
maxAgeMs: number,
|