pi-tmux-session-map 0.3.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 +44 -9
- package/package.json +1 -1
- package/src/continuation-executor.ts +323 -0
- package/src/domain/atomic-write.ts +40 -9
- package/src/domain/continuation.ts +374 -0
- package/src/domain/mapping-publisher.ts +54 -0
- package/src/domain/pane-info.ts +4 -1
- package/src/domain/pane-key.ts +5 -0
- package/src/domain/pane-ownership.ts +23 -0
- package/src/domain/session-file.ts +55 -0
- package/src/domain/session-mapping.ts +89 -24
- package/src/domain/signal-evidence.ts +56 -0
- package/src/domain/status.ts +55 -0
- package/src/index.ts +263 -41
package/README.md
CHANGED
|
@@ -10,27 +10,57 @@ It also writes [tws](https://github.com/ytaskiran/tws)-compatible Pi work-status
|
|
|
10
10
|
|
|
11
11
|
## How it works
|
|
12
12
|
|
|
13
|
-
The extension
|
|
13
|
+
The extension records two complementary tmux identities:
|
|
14
14
|
|
|
15
|
-
```
|
|
16
|
-
#{session_name}:#{window_index}.#{pane_index}
|
|
15
|
+
```text
|
|
16
|
+
restart identity: #{session_name}:#{window_index}.#{pane_index}
|
|
17
|
+
live identity: (#{pid}, #{pane_id})
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
The pane key is what tmux-resurrect recreates across server restarts. The server-PID/pane-ID pair remains stable while one tmux server is running, even when windows or panes are renumbered. A periodic, jittered reconciliation resolves the current key for the live pane ID and atomically republishes the mapping under the new key. Pane IDs are never trusted across a tmux server restart because tmux can reuse them.
|
|
21
|
+
|
|
22
|
+
File names use a readable sanitized prefix plus a 12-character SHA-256 suffix so different identities cannot collide and very long tmux session names stay below filesystem filename limits.
|
|
20
23
|
|
|
21
24
|
For tmux it writes the exact-resume mapping and status sidecar. For Zellij it writes only the status sidecar because Zellij owns session resurrection itself:
|
|
22
25
|
|
|
23
26
|
```text
|
|
24
27
|
~/.local/state/pi/tmux-sessions/<sanitized-pane-key>-<sha256-12>.session
|
|
28
|
+
~/.local/state/pi/tmux-sessions/pane-id-<sanitized-pane-id>-<sha256-12>.session
|
|
25
29
|
~/.config/tws/pi-status/<sanitized-pane-key>-<sha256-12>.json
|
|
26
30
|
~/.config/tws-zellij/pi-status/<sanitized-pane-key>-<sha256-12>.json
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
The `.session`
|
|
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.
|
|
30
60
|
|
|
31
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.
|
|
32
62
|
|
|
33
|
-
Mappings are intentionally **not** removed on shutdown — they must survive `tmux kill-server` so resurrect can use them.
|
|
63
|
+
Mappings are intentionally **not** removed on shutdown — they must survive `tmux kill-server` so resurrect can use them. During one live tmux server, reconciliation self-heals pane renumbering through the stable pane-ID alias. After a server restart, consumers ignore old aliases and use the restart-stable pane key. Very old missing-target mappings and interrupted atomic-write temp files are cleaned up opportunistically on session start.
|
|
34
64
|
|
|
35
65
|
## Install
|
|
36
66
|
|
|
@@ -95,13 +125,14 @@ set -g @resurrect-processes '"~pi-coding-agent/dist/cli.js->pi-tmux-resume"'
|
|
|
95
125
|
|
|
96
126
|
## Behavior
|
|
97
127
|
|
|
98
|
-
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.
|
|
99
129
|
|
|
100
130
|
If the recorded session file no longer exists, the wrapper falls back to `pi --continue` for the pane's working directory.
|
|
101
131
|
|
|
102
132
|
## Caveats
|
|
103
133
|
|
|
104
|
-
- Pane
|
|
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.
|
|
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.
|
|
105
136
|
|
|
106
137
|
## License
|
|
107
138
|
|
|
@@ -116,8 +147,12 @@ Optional environment variables for hardening or local integration:
|
|
|
116
147
|
- `PI_TMUX_SESSION_MAP_TWS_STATUS_DIR` — override only the tws status directory.
|
|
117
148
|
- `PI_TMUX_SESSION_MAP_TWS_TRIGGER_FILE` — override the tws trigger file.
|
|
118
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`.
|
|
151
|
+
- `PI_TMUX_SESSION_MAP_RECONCILE_INTERVAL_MS` — periodic mapping reconciliation interval; minimum 10 seconds, default 60 seconds, deterministically jittered per pane.
|
|
119
152
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_BIN` — override the `zellij` binary path.
|
|
120
153
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_STATUS_DIR` — override the Zellij TWS status directory.
|
|
121
154
|
- `PI_TMUX_SESSION_MAP_ZELLIJ_TRIGGER_FILE` — override the Zellij TWS trigger file.
|
|
122
155
|
|
|
123
|
-
|
|
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,7 +1,7 @@
|
|
|
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
|
-
import { mappedSessionFile } from "./session-mapping.ts";
|
|
4
|
+
import { mappedPaneIdentity, mappedSessionFile } from "./session-mapping.ts";
|
|
5
5
|
|
|
6
6
|
let atomicWriteCounter = 0;
|
|
7
7
|
|
|
@@ -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,
|
|
@@ -108,8 +130,9 @@ export async function cleanupSupersededStatusFiles(
|
|
|
108
130
|
|
|
109
131
|
export async function cleanupSupersededSessionMappings(
|
|
110
132
|
dir: string,
|
|
111
|
-
|
|
133
|
+
currentFileNames: string | readonly string[],
|
|
112
134
|
sessionFile: string,
|
|
135
|
+
paneIdentity?: { paneId: string; tmuxServerPid: string },
|
|
113
136
|
): Promise<CleanupResult> {
|
|
114
137
|
let entries: string[];
|
|
115
138
|
try {
|
|
@@ -118,20 +141,28 @@ export async function cleanupSupersededSessionMappings(
|
|
|
118
141
|
return { scanned: 0, removed: 0 };
|
|
119
142
|
}
|
|
120
143
|
|
|
144
|
+
const retained = new Set(
|
|
145
|
+
typeof currentFileNames === "string" ? [currentFileNames] : currentFileNames,
|
|
146
|
+
);
|
|
121
147
|
let scanned = 0;
|
|
122
148
|
let removed = 0;
|
|
123
149
|
for (const entry of entries) {
|
|
124
|
-
if (!entry.endsWith(".session") || entry
|
|
150
|
+
if (!entry.endsWith(".session") || retained.has(entry)) {
|
|
125
151
|
continue;
|
|
126
152
|
}
|
|
127
153
|
scanned += 1;
|
|
128
154
|
const path = join(dir, entry);
|
|
129
155
|
try {
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
const content = await readFile(path, "utf8");
|
|
157
|
+
// A Pi session and a live tmux pane id each have one current owner. A
|
|
158
|
+
// match on either identity proves this is an old key or alias, but only
|
|
159
|
+
// after the caller has atomically published the replacement files.
|
|
160
|
+
const sameSession = mappedSessionFile(content) === sessionFile;
|
|
161
|
+
const mappedIdentity = mappedPaneIdentity(content);
|
|
162
|
+
const samePane = paneIdentity !== undefined
|
|
163
|
+
&& mappedIdentity?.paneId === paneIdentity.paneId
|
|
164
|
+
&& mappedIdentity.tmuxServerPid === paneIdentity.tmuxServerPid;
|
|
165
|
+
if (!sameSession && !samePane) {
|
|
135
166
|
continue;
|
|
136
167
|
}
|
|
137
168
|
await rm(path, { force: true });
|