tokenmaw 0.3.0 → 0.4.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.
@@ -0,0 +1,256 @@
1
+ import { homedir } from 'node:os';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
+ import { resolve } from 'node:path';
7
+ const execFileAsync = promisify(execFile);
8
+ const LOCK_FORMAT_VERSION = 1;
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ const POLL_INTERVAL_MS = 50;
11
+ export class LockConflictError extends Error {
12
+ holder;
13
+ constructor(message, holder) {
14
+ super(message);
15
+ this.name = 'LockConflictError';
16
+ this.holder = holder;
17
+ }
18
+ }
19
+ export function defaultLockDir() {
20
+ const base = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder');
21
+ return resolve(base, 'runtime', 'locks');
22
+ }
23
+ function lockKey(target) {
24
+ return resolve(target);
25
+ }
26
+ function lockFileName(target) {
27
+ return `${createHash('sha1').update(lockKey(target)).digest('hex')}.lock`;
28
+ }
29
+ // ── Process liveness ─────────────────────────────────────────────────────────
30
+ let cachedSelfStart;
31
+ /** Epoch ms this Node process started (second precision, like `ps lstart`). */
32
+ export function selfStartedAt() {
33
+ if (cachedSelfStart === undefined) {
34
+ cachedSelfStart = Math.floor((Date.now() - process.uptime() * 1000) / 1000) * 1000;
35
+ }
36
+ return cachedSelfStart;
37
+ }
38
+ const startTimeCache = new Map();
39
+ async function processStartTime(pid) {
40
+ if (pid === process.pid)
41
+ return selfStartedAt();
42
+ if (startTimeCache.has(pid))
43
+ return startTimeCache.get(pid);
44
+ let value;
45
+ try {
46
+ if (process.platform !== 'win32') {
47
+ const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'lstart='], { timeout: 5_000 });
48
+ const parsed = Date.parse(stdout.trim());
49
+ if (Number.isFinite(parsed))
50
+ value = parsed;
51
+ }
52
+ }
53
+ catch {
54
+ value = undefined;
55
+ }
56
+ startTimeCache.set(pid, value);
57
+ return value;
58
+ }
59
+ function processAlive(pid) {
60
+ if (pid === process.pid)
61
+ return true;
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ }
66
+ catch (error) {
67
+ return error.code === 'EPERM';
68
+ }
69
+ }
70
+ /** True when the recorded holder still owns the lock: process alive and, when
71
+ * verifiable, its start time matches the recorded one (defeats pid reuse). */
72
+ async function holderIsLive(info) {
73
+ if (!processAlive(info.pid))
74
+ return false;
75
+ const actual = await processStartTime(info.pid);
76
+ if (actual === undefined)
77
+ return true;
78
+ return Math.abs(actual - info.startedAt) < 1_500;
79
+ }
80
+ // ── Manager ──────────────────────────────────────────────────────────────────
81
+ /** Nonces of locks currently held by this process, keyed by resolved target.
82
+ * Process-wide (not per-manager) so two managers in one process — e.g. two
83
+ * AgentRuntimes — still recognize each other's live locks instead of stealing
84
+ * them as "same-pid leaks". */
85
+ const processHeldNonces = new Map();
86
+ function processHolds(key) {
87
+ const set = processHeldNonces.get(key);
88
+ return Boolean(set && set.size > 0);
89
+ }
90
+ function trackHeld(key, nonce) {
91
+ const set = processHeldNonces.get(key) ?? new Set();
92
+ set.add(nonce);
93
+ processHeldNonces.set(key, set);
94
+ }
95
+ function untrackHeld(key, nonce) {
96
+ const set = processHeldNonces.get(key);
97
+ if (!set)
98
+ return;
99
+ set.delete(nonce);
100
+ if (set.size === 0)
101
+ processHeldNonces.delete(key);
102
+ }
103
+ async function readLockFile(path) {
104
+ let raw;
105
+ try {
106
+ raw = await readFile(path, 'utf8');
107
+ }
108
+ catch (error) {
109
+ return { corrupt: false, missing: error.code === 'ENOENT' };
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(raw);
113
+ if (parsed.v !== LOCK_FORMAT_VERSION || typeof parsed.pid !== 'number' || typeof parsed.nonce !== 'string') {
114
+ return { corrupt: true, missing: false };
115
+ }
116
+ return { info: parsed, corrupt: false, missing: false };
117
+ }
118
+ catch {
119
+ return { corrupt: true, missing: false };
120
+ }
121
+ }
122
+ export class CrossProcessLockManager {
123
+ dir;
124
+ constructor(lockDir = defaultLockDir()) {
125
+ this.dir = resolve(lockDir);
126
+ }
127
+ lockPath(target) {
128
+ return resolve(this.dir, lockFileName(target));
129
+ }
130
+ /** Current holder metadata for diagnostics, if any lock file exists. */
131
+ async holder(target) {
132
+ const path = this.lockPath(target);
133
+ const read = await readLockFile(path);
134
+ if (!read.info)
135
+ return undefined;
136
+ return { ...read.info, live: await holderIsLive(read.info) };
137
+ }
138
+ async acquire(target, options = {}) {
139
+ const key = lockKey(target);
140
+ const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
141
+ const deadline = Date.now() + timeoutMs;
142
+ const info = {
143
+ pid: process.pid,
144
+ startedAt: selfStartedAt(),
145
+ nonce: randomUUID(),
146
+ purpose: options.purpose ?? 'file',
147
+ target: key,
148
+ acquiredAt: new Date().toISOString(),
149
+ ...(options.session ? { session: options.session } : {}),
150
+ };
151
+ const payload = `${JSON.stringify({ v: LOCK_FORMAT_VERSION, ...info }, null, 2)}\n`;
152
+ await mkdir(this.dir, { recursive: true });
153
+ let lastHolder;
154
+ let stealAttempts = 0;
155
+ for (;;) {
156
+ if (processHolds(key)) {
157
+ // Another manager instance in this process holds the lock; the
158
+ // in-process layer above must serialize. Reaching here means a
159
+ // leaked handle or a second runtime in the same process.
160
+ throw new LockConflictError(`write lock for ${key} is held by this process (pid ${process.pid}); a previous acquisition was never released`, { ...(await this.holderOrSelf(key, info)), live: true });
161
+ }
162
+ const path = this.lockPath(key);
163
+ let handle;
164
+ try {
165
+ handle = await open(path, 'wx');
166
+ }
167
+ catch (error) {
168
+ if (error.code !== 'EEXIST')
169
+ throw error;
170
+ const read = await readLockFile(path);
171
+ if (read.info) {
172
+ const sameProcessLeak = read.info.pid === process.pid && !processHolds(key);
173
+ const live = sameProcessLeak ? false : await holderIsLive(read.info);
174
+ lastHolder = { ...read.info, live: !sameProcessLeak && live };
175
+ if (live) {
176
+ if (Date.now() >= deadline) {
177
+ throw this.conflict(key, lastHolder);
178
+ }
179
+ await sleep(POLL_INTERVAL_MS);
180
+ continue;
181
+ }
182
+ // Stale lock: holder process is gone, the pid was reused, or the
183
+ // same process leaked an unreleased lock — steal it.
184
+ stealAttempts += 1;
185
+ if (stealAttempts > 10)
186
+ throw this.conflict(key, lastHolder);
187
+ await rm(path, { force: true }).catch(() => undefined);
188
+ continue;
189
+ }
190
+ if (read.corrupt) {
191
+ // Unparseable lock file is stale by definition.
192
+ stealAttempts += 1;
193
+ if (stealAttempts > 10)
194
+ throw this.conflict(key, lastHolder);
195
+ await rm(path, { force: true }).catch(() => undefined);
196
+ continue;
197
+ }
198
+ // missing: someone removed it between EEXIST and read — retry immediately.
199
+ continue;
200
+ }
201
+ try {
202
+ await handle.writeFile(payload, 'utf8');
203
+ await handle.close();
204
+ }
205
+ catch (error) {
206
+ await handle.close().catch(() => undefined);
207
+ await rm(path, { force: true }).catch(() => undefined);
208
+ throw error;
209
+ }
210
+ trackHeld(key, info.nonce);
211
+ let released = false;
212
+ return {
213
+ nonce: info.nonce,
214
+ path,
215
+ release: async () => {
216
+ if (released)
217
+ return;
218
+ released = true;
219
+ untrackHeld(key, info.nonce);
220
+ const current = await readLockFile(path);
221
+ if (current.info?.nonce === info.nonce) {
222
+ await rm(path, { force: true }).catch(() => undefined);
223
+ }
224
+ },
225
+ };
226
+ }
227
+ }
228
+ async holderOrSelf(key, fallback) {
229
+ const found = await this.holder(key);
230
+ return found ?? { ...fallback, target: key };
231
+ }
232
+ conflict(key, holder) {
233
+ if (!holder) {
234
+ return new LockConflictError(`write lock for ${key} could not be acquired before timeout`);
235
+ }
236
+ const who = holder.session ? `session ${holder.session}` : holder.purpose;
237
+ const state = holder.live ? 'active' : 'possibly stale';
238
+ return new LockConflictError(`write lock for ${key} is held by another process (pid ${holder.pid}, ${who}, ${state}, acquired ${holder.acquiredAt}). ` +
239
+ `Wait for it to finish${holder.live ? ', or terminate pid ' + holder.pid + ' if it is wedged' : ''}.`, holder);
240
+ }
241
+ }
242
+ function sleep(ms) {
243
+ return new Promise((resolveWait) => setTimeout(resolveWait, ms));
244
+ }
245
+ /** Atomic file replace used by instance heartbeat files etc. */
246
+ export async function atomicReplaceFile(path, content) {
247
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
248
+ await writeFile(temp, content, 'utf8');
249
+ try {
250
+ await rename(temp, path);
251
+ }
252
+ catch (error) {
253
+ await rm(temp, { force: true }).catch(() => undefined);
254
+ throw error;
255
+ }
256
+ }
@@ -1,46 +1,66 @@
1
1
  import { resolve } from 'node:path';
2
+ import { CrossProcessLockManager, } from './file-lock.js';
3
+ export { LockConflictError } from './file-lock.js';
4
+ /**
5
+ * Per-path write lock that is safe both within one process (concurrent agent
6
+ * instances) and across processes (multiple runtimes on the same workspace).
7
+ *
8
+ * In-process: a FIFO promise chain per resolved path serializes concurrent
9
+ * acquirers. Cross-process: an O_EXCL lock file under the runtime lock dir.
10
+ * The returned release function is bound to the acquisition token and is
11
+ * idempotent — calling it twice can never hand the lock to another waiter
12
+ * while the original holder is still inside its critical section.
13
+ */
2
14
  export class FileLockManager {
3
- active = new Set();
4
- waiters = new Map();
5
- async acquire(path, timeoutMs = 30_000) {
15
+ cross;
16
+ chains = new Map();
17
+ constructor(lockDir) {
18
+ this.cross = new CrossProcessLockManager(lockDir);
19
+ }
20
+ /** Diagnostics: who (if anyone) currently holds the cross-process lock. */
21
+ async holder(path) {
22
+ return this.cross.holder(resolve(path));
23
+ }
24
+ async acquire(path, timeoutMs = 30_000, options = {}) {
6
25
  const key = resolve(path);
7
- if (!this.active.has(key)) {
8
- this.active.add(key);
9
- return () => this.release(key);
10
- }
11
- await new Promise((resolveWait, reject) => {
12
- const queue = this.waiters.get(key) ?? [];
13
- const waiter = {
14
- resume: resolveWait,
15
- reject,
16
- timer: setTimeout(() => {
17
- const current = this.waiters.get(key);
18
- const index = current?.indexOf(waiter) ?? -1;
19
- if (index >= 0)
20
- current.splice(index, 1);
21
- if (current?.length === 0)
22
- this.waiters.delete(key);
23
- reject(new Error(`Timed out waiting for write lock: ${key}`));
24
- }, Math.max(100, timeoutMs)),
25
- };
26
- queue.push(waiter);
27
- this.waiters.set(key, queue);
26
+ const previous = this.chains.get(key) ?? Promise.resolve();
27
+ let openGate;
28
+ const gate = new Promise((releaseTurn) => {
29
+ openGate = releaseTurn;
28
30
  });
29
- this.active.add(key);
30
- return () => this.release(key);
31
- }
32
- release(key) {
33
- const queue = this.waiters.get(key);
34
- const next = queue?.shift();
35
- if (!queue || queue.length === 0) {
36
- this.waiters.delete(key);
37
- }
38
- if (next) {
39
- clearTimeout(next.timer);
40
- next.resume();
31
+ const chain = previous.then(() => gate);
32
+ this.chains.set(key, chain);
33
+ // Wait for the in-process turn. In-process holders always release (the
34
+ // release path cannot throw past its finally), so no timeout is needed here.
35
+ await previous;
36
+ let handle;
37
+ try {
38
+ handle = await this.cross.acquire(key, {
39
+ timeoutMs: Math.max(100, timeoutMs),
40
+ ...options,
41
+ });
41
42
  }
42
- else {
43
- this.active.delete(key);
43
+ catch (error) {
44
+ openGate();
45
+ this.dropChain(key, chain);
46
+ throw error;
44
47
  }
48
+ let released = false;
49
+ return async () => {
50
+ if (released)
51
+ return; // idempotent second release
52
+ released = true;
53
+ try {
54
+ await handle.release();
55
+ }
56
+ finally {
57
+ openGate();
58
+ this.dropChain(key, chain);
59
+ }
60
+ };
61
+ }
62
+ dropChain(key, chain) {
63
+ if (this.chains.get(key) === chain)
64
+ this.chains.delete(key);
45
65
  }
46
66
  }
@@ -16,9 +16,29 @@ function runningIndex(session, entries) {
16
16
  return index;
17
17
  }
18
18
  function finish(entries, predicate, status = 'completed') {
19
+ const endedAt = Date.now();
20
+ // Freeze endedAt only on the transition to a terminal status so a repeated
21
+ // finish pass never re-extends a duration that was already frozen.
19
22
  for (const entry of entries)
20
- if (predicate(entry))
23
+ if (predicate(entry) && !entry.endedAt && entry.status !== status) {
21
24
  entry.status = status;
25
+ entry.endedAt = endedAt;
26
+ }
27
+ }
28
+ /** Record a user-typed `!command` run as a transcript entry. Shell entries
29
+ * stream inline in the conversation but never reach the model context; the
30
+ * caller mutates `content`/`status` as output arrives and the process exits. */
31
+ export function recordShellRun(session, command) {
32
+ const entries = session.timeline ??= session.messages.map(message => ({
33
+ id: message.messageId, kind: 'message', role: message.role,
34
+ turnId: message.turnId, content: message.content, status: 'completed',
35
+ }));
36
+ const entry = {
37
+ id: randomUUID(), kind: 'shell', turnId: undefined, tool: 'shell',
38
+ input: command, content: '', status: 'running', startedAt: Date.now(),
39
+ };
40
+ entries.push(entry);
41
+ return entry;
22
42
  }
23
43
  /** Record display order at event time, not grouped retrospectively by turn. */
24
44
  export function recordTimeline(session, event) {
@@ -35,6 +55,14 @@ export function recordTimeline(session, event) {
35
55
  });
36
56
  return;
37
57
  }
58
+ if (event.type === 'system_message') {
59
+ if (!entries.some(entry => entry.id === event.message.messageId))
60
+ entries.push({
61
+ id: event.message.messageId, kind: 'message', role: 'system', content: event.message.content,
62
+ status: 'completed',
63
+ });
64
+ return;
65
+ }
38
66
  if (!('instanceId' in event) || !event.instanceId) {
39
67
  if (event.type === 'instance_updated' && ['idle', 'failed', 'cancelled', 'queued'].includes(event.instance.status)) {
40
68
  const active = index.get(event.instance.instanceId) ?? [];
@@ -53,7 +81,7 @@ export function recordTimeline(session, event) {
53
81
  if (!entry || entry.kind !== kind || entry.turnId !== event.turnId) {
54
82
  finish(active, previous => previous.kind !== 'tool');
55
83
  entry = { id: randomUUID(), kind, instanceId: event.instanceId, turnId: event.turnId,
56
- role: 'assistant', content: '', status: 'running' };
84
+ role: 'assistant', content: '', status: 'running', startedAt: Date.now() };
57
85
  entries.push(entry);
58
86
  index.set(event.instanceId, [...active.filter(previous => previous.status === 'running'), entry]);
59
87
  }
@@ -72,7 +100,7 @@ export function recordTimeline(session, event) {
72
100
  const active = own();
73
101
  finish(active, entry => entry.kind !== 'tool');
74
102
  const entry = { id: randomUUID(), kind: 'tool', instanceId: event.instanceId,
75
- turnId: event.turnId, tool: event.tool, input: event.input, content: '', status: 'running' };
103
+ turnId: event.turnId, tool: event.tool, input: event.input, content: '', status: 'running', startedAt: Date.now() };
76
104
  entries.push(entry);
77
105
  index.set(event.instanceId, [...active.filter(previous => previous.status === 'running'), entry]);
78
106
  }
@@ -82,6 +110,7 @@ export function recordTimeline(session, event) {
82
110
  if (entry) {
83
111
  entry.content = event.output;
84
112
  entry.status = /^(?:\w*Error:|Error\b)|"ok"\s*:\s*false/.test(event.output) ? 'failed' : 'completed';
113
+ entry.endedAt = Date.now();
85
114
  const remaining = active.filter(previous => previous.status === 'running');
86
115
  if (remaining.length)
87
116
  index.set(event.instanceId, remaining);
@@ -0,0 +1,109 @@
1
+ import { homedir } from 'node:os';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { atomicReplaceFile, selfStartedAt } from './file-lock.js';
6
+ const HEARTBEAT_INTERVAL_MS = 5_000;
7
+ const STALE_AFTER_MS = 30_000;
8
+ function instancesDir() {
9
+ const base = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder');
10
+ return { dir: resolve(base, 'runtime', 'instances') };
11
+ }
12
+ function instanceFile(workspaceRoot, pid) {
13
+ const key = createHash('sha1').update(resolve(workspaceRoot)).digest('hex').slice(0, 16);
14
+ return join(instancesDir().dir, `${key}.${pid}.json`);
15
+ }
16
+ function processAlive(pid) {
17
+ if (pid === process.pid)
18
+ return true;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return error.code === 'EPERM';
25
+ }
26
+ }
27
+ async function heartbeatFile(path, info) {
28
+ info.heartbeatAt = new Date().toISOString();
29
+ await mkdir(instancesDir().dir, { recursive: true });
30
+ await atomicReplaceFile(path, `${JSON.stringify(info, null, 2)}\n`);
31
+ }
32
+ /**
33
+ * Registers this process as an active instance of `workspaceRoot` and starts a
34
+ * heartbeat. Returns a cleanup function that removes the registration (also
35
+ * installed on process exit). Other instances whose pid is dead or whose
36
+ * heartbeat is older than the stale window are ignored by `otherInstances`.
37
+ */
38
+ export async function registerWorkspaceInstance(workspaceRoot, session) {
39
+ const info = {
40
+ pid: process.pid,
41
+ startedAt: selfStartedAt(),
42
+ workspace: resolve(workspaceRoot),
43
+ ...(session ? { session } : {}),
44
+ nonce: randomUUID(),
45
+ heartbeatAt: new Date().toISOString(),
46
+ };
47
+ const path = instanceFile(workspaceRoot, info.pid);
48
+ await mkdir(instancesDir().dir, { recursive: true });
49
+ await atomicReplaceFile(path, `${JSON.stringify(info, null, 2)}\n`);
50
+ const timer = setInterval(() => {
51
+ void heartbeatFile(path, info).catch(() => undefined);
52
+ }, HEARTBEAT_INTERVAL_MS);
53
+ timer.unref?.();
54
+ let cleaned = false;
55
+ const cleanup = async () => {
56
+ if (cleaned)
57
+ return;
58
+ cleaned = true;
59
+ clearInterval(timer);
60
+ await rm(path, { force: true }).catch(() => undefined);
61
+ };
62
+ process.once('exit', () => { void cleanup(); });
63
+ return cleanup;
64
+ }
65
+ function isLive(info, mtimeMs) {
66
+ if (!processAlive(info.pid))
67
+ return false;
68
+ if (mtimeMs > 0 && Date.now() - mtimeMs > STALE_AFTER_MS)
69
+ return false;
70
+ return true;
71
+ }
72
+ /** Live instances of the same workspace other than this process. */
73
+ export async function otherWorkspaceInstances(workspaceRoot) {
74
+ const dir = instancesDir().dir;
75
+ const key = createHash('sha1').update(resolve(workspaceRoot)).digest('hex').slice(0, 16);
76
+ let files = [];
77
+ try {
78
+ files = (await readdir(dir)).filter((name) => name.startsWith(`${key}.`) && name.endsWith('.json'));
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const others = [];
84
+ for (const file of files) {
85
+ const path = join(dir, file);
86
+ try {
87
+ const [raw, info] = await Promise.all([readFile(path, 'utf8'), stat(path)]);
88
+ const parsed = JSON.parse(raw);
89
+ if (typeof parsed.pid !== 'number')
90
+ continue;
91
+ if (parsed.pid === process.pid)
92
+ continue;
93
+ if (isLive(parsed, info.mtimeMs))
94
+ others.push(parsed);
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ }
100
+ return others.sort((a, b) => a.pid - b.pid);
101
+ }
102
+ /** Touch helper exposed for tests. */
103
+ export async function touchInstanceFile(workspaceRoot, pid) {
104
+ const path = instanceFile(workspaceRoot, pid);
105
+ const now = new Date();
106
+ await utimes(path, now, now).catch(async () => {
107
+ await writeFile(path, '').catch(() => undefined);
108
+ });
109
+ }