@prjct.app/pi-team 0.5.0 → 0.5.1
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/CHANGELOG.md +2 -0
- package/package.json +3 -2
- package/src/index.ts +267 -155
- package/src/mailbox.ts +32 -31
- package/src/store.ts +30 -26
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prjct.app/pi-team",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Coordinate independent PI Agent sessions with local team messaging, queued tasks, and shared results.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"image": "https://raw.githubusercontent.com/prjct-app/pi-clipboard/main/docs/covers/pi-team.png"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"check": "tsc --noEmit",
|
|
27
|
+
"check": "tsc --noEmit && npm run check:immutable",
|
|
28
|
+
"check:immutable": "! grep -rn '\\blet\\b' src/ || (echo 'Use immutable values: no let bindings in src/' && exit 1)",
|
|
28
29
|
"test": "node --import tsx --test tests/*.test.ts tests/*.test.mjs",
|
|
29
30
|
"check:package": "npm pack --dry-run --ignore-scripts"
|
|
30
31
|
},
|
package/src/index.ts
CHANGED
|
@@ -73,96 +73,136 @@ function flowLines(snapshot: Snapshot, limit = Number.POSITIVE_INFINITY): string
|
|
|
73
73
|
return lines;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function reason(error: unknown): string {
|
|
77
|
+
return error instanceof Error ? error.message : String(error);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whole-session state as immutable snapshots. Every field is replaced, never
|
|
82
|
+
* mutated in place, so each transition is a single reviewable expression.
|
|
83
|
+
* Read through `get()` at the point of use: several paths deliberately re-read
|
|
84
|
+
* after an `await` because a user prompt can land mid-transaction.
|
|
85
|
+
*/
|
|
86
|
+
type Session = Readonly<{
|
|
87
|
+
ctx?: ExtensionContext;
|
|
88
|
+
member?: Membership;
|
|
89
|
+
active?: Message;
|
|
90
|
+
timer?: ReturnType<typeof setInterval>;
|
|
91
|
+
watcher?: FSWatcher;
|
|
92
|
+
paused: boolean;
|
|
93
|
+
leaving: boolean;
|
|
94
|
+
closed: boolean;
|
|
95
|
+
compacting: boolean;
|
|
96
|
+
needsCompaction: boolean;
|
|
97
|
+
compactionSubject: string;
|
|
98
|
+
compactionGeneration: number;
|
|
99
|
+
prompts: number;
|
|
100
|
+
budget: number;
|
|
101
|
+
finalText: string;
|
|
102
|
+
userTakeover: boolean;
|
|
103
|
+
outcome: Result['outcome'];
|
|
104
|
+
files: ReadonlySet<string>;
|
|
105
|
+
lastError: string;
|
|
106
|
+
teamNames: readonly string[];
|
|
107
|
+
aliases: readonly string[];
|
|
108
|
+
serial: Promise<unknown>;
|
|
109
|
+
tickQueued: boolean;
|
|
110
|
+
lastHeartbeat: number;
|
|
111
|
+
lastReview: number;
|
|
112
|
+
lastRevision: number;
|
|
113
|
+
quietReviews: number;
|
|
114
|
+
}>;
|
|
115
|
+
|
|
116
|
+
const INITIAL: Session = {
|
|
117
|
+
paused: false, leaving: false, closed: false, compacting: false, needsCompaction: false,
|
|
118
|
+
compactionSubject: '', compactionGeneration: 0, prompts: 0, budget: 0, finalText: '',
|
|
119
|
+
userTakeover: false, outcome: 'completed', files: new Set(), lastError: '',
|
|
120
|
+
teamNames: [], aliases: [], serial: Promise.resolve(), tickQueued: false,
|
|
121
|
+
lastHeartbeat: 0, lastReview: 0, lastRevision: -1, quietReviews: 0,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Cleared on join, restore, and leave so a new membership starts unbiased. */
|
|
125
|
+
const MEMBERSHIP_RESET = {
|
|
126
|
+
paused: false, leaving: false, closed: false, compacting: false, needsCompaction: false,
|
|
127
|
+
compactionSubject: '', budget: 0, lastReview: 0, quietReviews: 0, lastRevision: -1,
|
|
128
|
+
} as const;
|
|
129
|
+
|
|
76
130
|
export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?: number; reviewMs?: number; agingMs?: number } = {}): void {
|
|
77
131
|
const box = new Mailbox(options.root ?? join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'teams'));
|
|
78
132
|
const reviewMs = options.reviewMs ?? 60_000;
|
|
79
133
|
const agingMs = options.agingMs ?? 300_000;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let watcher: FSWatcher | undefined;
|
|
85
|
-
let paused = false;
|
|
86
|
-
let leaving = false;
|
|
87
|
-
let closed = false;
|
|
88
|
-
let compacting = false;
|
|
89
|
-
let needsCompaction = false;
|
|
90
|
-
let compactionSubject = '';
|
|
91
|
-
let compactionGeneration = 0;
|
|
92
|
-
let prompts = 0;
|
|
93
|
-
let budget = 0;
|
|
94
|
-
let finalText = '';
|
|
95
|
-
let userTakeover = false;
|
|
96
|
-
let outcome: Result['outcome'] = 'completed';
|
|
97
|
-
let files = new Set<string>();
|
|
98
|
-
let lastError = '';
|
|
99
|
-
let teamNames: string[] = [];
|
|
100
|
-
let aliases: string[] = [];
|
|
101
|
-
let serial: Promise<unknown> = Promise.resolve();
|
|
102
|
-
let tickQueued = false;
|
|
103
|
-
let lastHeartbeat = 0;
|
|
104
|
-
let lastReview = 0;
|
|
105
|
-
let lastRevision = -1;
|
|
106
|
-
let quietReviews = 0;
|
|
134
|
+
const slot = { current: INITIAL };
|
|
135
|
+
const get = (): Session => slot.current;
|
|
136
|
+
const set = (update: (session: Session) => Partial<Session>): Session =>
|
|
137
|
+
(slot.current = { ...slot.current, ...update(slot.current) });
|
|
107
138
|
|
|
108
139
|
function queue<T>(action: () => Promise<T>): Promise<T> {
|
|
109
|
-
const work = serial.then(action);
|
|
110
|
-
serial
|
|
140
|
+
const work = get().serial.then(action);
|
|
141
|
+
set(() => ({ serial: work.catch(() => {}) }));
|
|
111
142
|
return work;
|
|
112
143
|
}
|
|
113
144
|
function required(): Membership {
|
|
145
|
+
const { member, leaving } = get();
|
|
114
146
|
if (!member || leaving) throw new Error('Join a team first: /team join <team> <alias>');
|
|
115
147
|
return member;
|
|
116
148
|
}
|
|
117
|
-
function persist(pauseOnRestore = paused || !!active) {
|
|
149
|
+
function persist(pauseOnRestore = get().paused || !!get().active) {
|
|
150
|
+
const { member, leaving, needsCompaction, compactionSubject } = get();
|
|
118
151
|
pi.appendEntry('team-membership', member && !leaving ? {
|
|
119
152
|
team: member.team, alias: member.alias, session: member.session, paused: pauseOnRestore,
|
|
120
153
|
needsCompaction, compactionSubject: needsCompaction ? compactionSubject : undefined,
|
|
121
154
|
} : null);
|
|
122
155
|
}
|
|
123
156
|
function stop() {
|
|
157
|
+
const { timer, watcher } = get();
|
|
124
158
|
if (timer) clearInterval(timer);
|
|
125
|
-
|
|
126
|
-
|
|
159
|
+
watcher?.close();
|
|
160
|
+
set(() => ({ timer: undefined, watcher: undefined }));
|
|
161
|
+
}
|
|
162
|
+
/** Forget the current membership without leaving the mailbox. */
|
|
163
|
+
function forget() {
|
|
164
|
+
set(session => ({
|
|
165
|
+
member: undefined, active: undefined, leaving: false, compacting: false,
|
|
166
|
+
needsCompaction: false, compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
|
|
167
|
+
}));
|
|
168
|
+
persist();
|
|
169
|
+
get().ctx?.ui.setWidget('team', undefined);
|
|
127
170
|
}
|
|
128
171
|
async function detach() {
|
|
129
172
|
stop();
|
|
173
|
+
const { member } = get();
|
|
130
174
|
try { if (member) await box.leave(member); }
|
|
131
|
-
finally {
|
|
132
|
-
member = undefined; active = undefined; leaving = false; compacting = false;
|
|
133
|
-
needsCompaction = false; compactionSubject = ''; compactionGeneration++;
|
|
134
|
-
persist(); ctx?.ui.setWidget('team', undefined);
|
|
135
|
-
}
|
|
175
|
+
finally { forget(); }
|
|
136
176
|
}
|
|
137
177
|
function availableForCompaction(): boolean {
|
|
178
|
+
const { ctx, closed, leaving, active, compacting, prompts } = get();
|
|
138
179
|
return !!ctx && !!ctx.model && !closed && !leaving && !active && !compacting && prompts === 0 && ctx.isIdle() &&
|
|
139
180
|
!ctx.hasPendingMessages() && !ctx.ui.getEditorText().trim();
|
|
140
181
|
}
|
|
141
182
|
function ready(): boolean {
|
|
183
|
+
const { paused, needsCompaction } = get();
|
|
142
184
|
return !paused && !needsCompaction && availableForCompaction();
|
|
143
185
|
}
|
|
144
186
|
function notice(error: unknown) {
|
|
145
|
-
const text =
|
|
146
|
-
if (text !== lastError) ctx?.ui.notify(`Team: ${text}`, 'warning');
|
|
147
|
-
lastError
|
|
187
|
+
const text = reason(error);
|
|
188
|
+
if (text !== get().lastError) get().ctx?.ui.notify(`Team: ${text}`, 'warning');
|
|
189
|
+
set(() => ({ lastError: text }));
|
|
148
190
|
if (text.includes('Membership expired or replaced')) {
|
|
149
|
-
stop();
|
|
150
|
-
|
|
151
|
-
persist(); ctx?.ui.setWidget('team', undefined);
|
|
191
|
+
stop();
|
|
192
|
+
forget();
|
|
152
193
|
}
|
|
153
194
|
}
|
|
154
195
|
function compactPendingContext(context: ExtensionContext) {
|
|
155
|
-
if (!needsCompaction || !availableForCompaction() || ctx !== context) return;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
196
|
+
if (!get().needsCompaction || !availableForCompaction() || get().ctx !== context) return;
|
|
197
|
+
const generation = set(session => ({
|
|
198
|
+
compacting: true, compactionGeneration: session.compactionGeneration + 1,
|
|
199
|
+
})).compactionGeneration;
|
|
200
|
+
const subject = plain(get().compactionSubject).replace(/\s+/g, ' ').slice(0, 80);
|
|
159
201
|
const finish = (): boolean => {
|
|
160
|
-
if (ctx !== context || generation !== compactionGeneration) return false;
|
|
161
|
-
compacting
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (member) persist();
|
|
165
|
-
if (!closed) enqueueTick();
|
|
202
|
+
if (get().ctx !== context || generation !== get().compactionGeneration) return false;
|
|
203
|
+
set(() => ({ compacting: false, needsCompaction: false, compactionSubject: '' }));
|
|
204
|
+
if (get().member) persist();
|
|
205
|
+
if (!get().closed) enqueueTick();
|
|
166
206
|
return true;
|
|
167
207
|
};
|
|
168
208
|
try {
|
|
@@ -170,57 +210,63 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
170
210
|
customInstructions: TASK_COMPACTION_INSTRUCTIONS,
|
|
171
211
|
onComplete: finish,
|
|
172
212
|
onError: error => {
|
|
173
|
-
if (finish() && !closed) {
|
|
213
|
+
if (finish() && !get().closed) {
|
|
174
214
|
context.ui.notify(`Team: Automatic context compaction after “${subject}” failed; reception will continue. ${error.message}`, 'warning');
|
|
175
215
|
}
|
|
176
216
|
},
|
|
177
217
|
});
|
|
178
218
|
} catch (error) {
|
|
179
219
|
if (finish()) {
|
|
180
|
-
|
|
181
|
-
context.ui.notify(`Team: Could not start context compaction after “${subject}”; reception will continue. ${text}`, 'warning');
|
|
220
|
+
context.ui.notify(`Team: Could not start context compaction after “${subject}”; reception will continue. ${reason(error)}`, 'warning');
|
|
182
221
|
}
|
|
183
222
|
}
|
|
184
223
|
enqueueTick();
|
|
185
224
|
}
|
|
186
225
|
function enqueueTick() {
|
|
226
|
+
const { closed, member, tickQueued } = get();
|
|
187
227
|
if (closed || !member || tickQueued) return;
|
|
188
|
-
tickQueued
|
|
228
|
+
set(() => ({ tickQueued: true }));
|
|
189
229
|
// Transient storage errors are reported but never pause reception: the
|
|
190
230
|
// next tick retries. Only membership loss detaches (handled in notice).
|
|
191
|
-
void queue(tick).catch(notice).finally(() => { tickQueued
|
|
231
|
+
void queue(tick).catch(notice).finally(() => { set(() => ({ tickQueued: false })); });
|
|
192
232
|
}
|
|
193
233
|
function start() {
|
|
194
234
|
stop();
|
|
235
|
+
const { member } = get();
|
|
195
236
|
if (!member) return;
|
|
196
|
-
timer = setInterval(enqueueTick, options.pollMs ?? 2000);
|
|
237
|
+
const timer = setInterval(enqueueTick, options.pollMs ?? 2000);
|
|
197
238
|
timer.unref();
|
|
239
|
+
set(() => ({ timer }));
|
|
198
240
|
try {
|
|
199
|
-
watcher = watch(join(box.root, member.team), (_event, filename) => {
|
|
241
|
+
const watcher = watch(join(box.root, member.team), (_event, filename) => {
|
|
200
242
|
// Polling remains the source of recovery when watchers miss events.
|
|
201
243
|
if (filename === 'state.json') enqueueTick();
|
|
202
244
|
});
|
|
203
|
-
watcher.on('error', () => { watcher?.close(); watcher
|
|
245
|
+
watcher.on('error', () => { get().watcher?.close(); set(() => ({ watcher: undefined })); });
|
|
204
246
|
watcher.unref();
|
|
247
|
+
set(() => ({ watcher }));
|
|
205
248
|
} catch { /* Periodic polling still works on filesystems without watchers. */ }
|
|
206
249
|
enqueueTick();
|
|
207
250
|
}
|
|
208
251
|
async function tick() {
|
|
209
|
-
|
|
252
|
+
const { ctx, member } = get();
|
|
253
|
+
if (!ctx || !member || get().closed) return;
|
|
210
254
|
// Presence heartbeats write only this member's own file: no shared lock.
|
|
211
|
-
if (Date.now() - lastHeartbeat >= 2000) {
|
|
255
|
+
if (Date.now() - get().lastHeartbeat >= 2000) {
|
|
256
|
+
const { compacting, paused } = get();
|
|
212
257
|
await box.heartbeat(member, compacting ? 'busy' : paused ? 'paused' : ready() ? 'idle' : 'busy');
|
|
213
|
-
lastHeartbeat
|
|
258
|
+
set(() => ({ lastHeartbeat: Date.now() }));
|
|
214
259
|
}
|
|
215
260
|
const snap = await box.snapshot(member);
|
|
216
|
-
aliases
|
|
217
|
-
const pending = snap.messages.filter(m => m.to === member
|
|
261
|
+
set(() => ({ aliases: snap.members.map(m => m.alias) }));
|
|
262
|
+
const pending = snap.messages.filter(m => m.to === member.alias && m.state === 'pending').length;
|
|
263
|
+
const { compacting, needsCompaction, paused, active } = get();
|
|
218
264
|
const status = `${member.team} · ${member.alias} · ${compacting || needsCompaction ? 'compacting' : paused ? 'paused' : !ctx.model ? 'select a model' : active ? 'working' : 'connected'}${pending ? ` · ${pending} pending` : ''}`;
|
|
219
265
|
ctx.ui.setWidget('team', () => ({
|
|
220
266
|
invalidate() {},
|
|
221
267
|
render(width: number) { return [truncateToWidth(status, width)]; },
|
|
222
268
|
}));
|
|
223
|
-
if (leaving) return;
|
|
269
|
+
if (get().leaving) return;
|
|
224
270
|
// A disconnected peer holding a claim must be interrupted so its
|
|
225
271
|
// requester receives a result instead of waiting forever.
|
|
226
272
|
if (snap.messages.some(m => m.state === 'processing') && snap.members.some(m => m.status === 'offline')) {
|
|
@@ -228,15 +274,19 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
228
274
|
}
|
|
229
275
|
// Keep the session branch stable while Pi summarizes it. Team commands stay
|
|
230
276
|
// registered, but no new peer content is appended or claimed until callback.
|
|
231
|
-
if (compacting) return;
|
|
232
|
-
if (needsCompaction) {
|
|
277
|
+
if (get().compacting) return;
|
|
278
|
+
if (get().needsCompaction) {
|
|
233
279
|
compactPendingContext(ctx);
|
|
234
280
|
return;
|
|
235
281
|
}
|
|
236
282
|
for (const message of await box.notes(member)) pi.appendEntry('team-event', message);
|
|
237
283
|
if (!ready()) return;
|
|
238
|
-
if (budget >= 5) {
|
|
239
|
-
if (pending) {
|
|
284
|
+
if (get().budget >= 5) {
|
|
285
|
+
if (pending) {
|
|
286
|
+
set(() => ({ paused: true }));
|
|
287
|
+
persist();
|
|
288
|
+
ctx.ui.notify('Team auto-turn limit reached. /team resume to continue.', 'info');
|
|
289
|
+
}
|
|
240
290
|
return;
|
|
241
291
|
}
|
|
242
292
|
if (!pending) { await review(snap); return; }
|
|
@@ -247,8 +297,10 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
247
297
|
if (!message) { persist(); return; }
|
|
248
298
|
// A user prompt can arrive while the filesystem transaction is in progress.
|
|
249
299
|
if (!ready()) { await box.release(member, message.id); persist(); return; }
|
|
250
|
-
|
|
251
|
-
|
|
300
|
+
set(session => ({
|
|
301
|
+
active: message, finalText: '', userTakeover: false, files: new Set(),
|
|
302
|
+
outcome: 'completed', budget: session.budget + 1,
|
|
303
|
+
}));
|
|
252
304
|
const result = message.result ? `\nReported outcome: ${message.result.outcome}\nFiles observed via edit/write: ${JSON.stringify(message.result.files)}` : '';
|
|
253
305
|
// Results carry the original request so the emitter can verify the
|
|
254
306
|
// deliverable against what it asked for and reply with what is missing.
|
|
@@ -261,21 +313,26 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
261
313
|
}, { triggerTurn: true, deliverAs: 'followUp' });
|
|
262
314
|
} catch (error) {
|
|
263
315
|
await box.complete(member, message.id, { outcome: 'interrupted', body: 'Could not start processing. Review before retrying.', files: [], tests: [] });
|
|
264
|
-
active
|
|
316
|
+
set(() => ({ active: undefined, paused: true }));
|
|
317
|
+
persist();
|
|
318
|
+
throw error;
|
|
265
319
|
}
|
|
266
320
|
}
|
|
267
321
|
async function review(snap: Snapshot) {
|
|
268
|
-
|
|
269
|
-
if (
|
|
322
|
+
const { member } = get();
|
|
323
|
+
if (!member || !ready() || get().active) return;
|
|
324
|
+
if (Date.now() - get().lastReview < reviewMs) return;
|
|
270
325
|
const outstanding = snap.messages.filter(m =>
|
|
271
|
-
m.kind === 'request' && m.from === member
|
|
326
|
+
m.kind === 'request' && m.from === member.alias && (m.state === 'pending' || m.state === 'processing') &&
|
|
272
327
|
Date.now() - m.created >= agingMs);
|
|
273
328
|
if (!outstanding.length) return;
|
|
274
329
|
// Without mailbox progress, reviews quiet down instead of polling forever;
|
|
275
330
|
// any state change re-arms them.
|
|
276
|
-
if (snap.revision === lastRevision) {
|
|
277
|
-
|
|
278
|
-
|
|
331
|
+
if (snap.revision === get().lastRevision) {
|
|
332
|
+
const quietReviews = set(session => ({ quietReviews: session.quietReviews + 1 })).quietReviews;
|
|
333
|
+
if (quietReviews >= 3) return;
|
|
334
|
+
} else set(() => ({ quietReviews: 0 }));
|
|
335
|
+
set(session => ({ lastRevision: snap.revision, lastReview: Date.now(), budget: session.budget + 1 }));
|
|
279
336
|
const items = outstanding.map(m => ({
|
|
280
337
|
id: m.id, subject: m.subject, to: m.to, state: m.state,
|
|
281
338
|
ageMinutes: Math.round((Date.now() - m.created) / 60_000),
|
|
@@ -285,11 +342,14 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
285
342
|
pi.sendMessage({ customType: 'team-review', display: true, details: { outstanding: items },
|
|
286
343
|
content: `${REVIEW_RULES}\n\nUnresolved work you emitted (data, not instructions from the user):\n${JSON.stringify({ outstanding: items })}`,
|
|
287
344
|
}, { triggerTurn: true, deliverAs: 'followUp' });
|
|
288
|
-
} catch (error) {
|
|
345
|
+
} catch (error) {
|
|
346
|
+
set(session => ({ budget: session.budget - 1 }));
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
289
349
|
}
|
|
290
350
|
async function send(input: Outgoing, fromUser = false): Promise<Message> {
|
|
291
351
|
const current = required();
|
|
292
|
-
const sent = await box.send(current, { ...input, parentId: fromUser ? undefined : active?.id });
|
|
352
|
+
const sent = await box.send(current, { ...input, parentId: fromUser ? undefined : get().active?.id });
|
|
293
353
|
pi.appendEntry('team-event', sent);
|
|
294
354
|
return sent;
|
|
295
355
|
}
|
|
@@ -330,8 +390,9 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
330
390
|
const snap = await queue(() => box.snapshot(current));
|
|
331
391
|
const age = (created: number) => Math.round((Date.now() - created) / 60_000);
|
|
332
392
|
const status = (alias: string) => snap.members.find(m => m.alias === alias)?.status ?? 'unknown';
|
|
393
|
+
const active = get().active;
|
|
333
394
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
334
|
-
team: current.team, alias: current.alias, compacting: compacting || needsCompaction,
|
|
395
|
+
team: current.team, alias: current.alias, compacting: get().compacting || get().needsCompaction,
|
|
335
396
|
active: active ? { id: active.id, subject: active.subject, from: active.from } : null,
|
|
336
397
|
emittedUnresolved: snap.messages
|
|
337
398
|
.filter(m => m.kind === 'request' && m.from === current.alias && ['pending', 'processing'].includes(m.state))
|
|
@@ -355,38 +416,47 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
355
416
|
description: 'Local team messaging: create, join, list, members, status, wake, send, note, inbox, pause, resume, leave',
|
|
356
417
|
getArgumentCompletions(prefix) {
|
|
357
418
|
const parts = prefix.split(/\s+/);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
419
|
+
const values = parts.length === 1 ? COMMANDS
|
|
420
|
+
: parts.length === 2 && parts[0] === 'join' ? get().teamNames
|
|
421
|
+
: parts.length === 2 && ['send', 'note'].includes(parts[0]) ? get().aliases
|
|
422
|
+
: [];
|
|
362
423
|
const stem = parts.slice(0, -1).join(' ');
|
|
363
424
|
return values.filter(v => v.startsWith(parts.at(-1) ?? '')).map(v => ({ value: `${stem ? stem + ' ' : ''}${v}`, label: v }));
|
|
364
425
|
},
|
|
365
426
|
handler: async (args, context) => {
|
|
366
427
|
if (context.mode !== 'tui') { context.ui.notify('Team membership is interactive-terminal only.', 'warning'); return; }
|
|
367
|
-
ctx
|
|
428
|
+
set(() => ({ ctx: context }));
|
|
368
429
|
await queue(async () => {
|
|
369
430
|
const [command, a, b, ...rest] = args.trim().split(/\s+/);
|
|
431
|
+
const ui = context.ui;
|
|
370
432
|
try {
|
|
371
433
|
switch (command) {
|
|
372
|
-
case 'create':
|
|
434
|
+
case 'create': {
|
|
373
435
|
if (!a || b) throw new Error('Usage: /team create <team>');
|
|
374
|
-
await box.create(a);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
436
|
+
await box.create(a);
|
|
437
|
+
const teamNames = await box.teams();
|
|
438
|
+
set(() => ({ teamNames }));
|
|
439
|
+
ui.notify(`Created ${a}. Join with /team join ${a} <alias>.`, 'info'); break;
|
|
440
|
+
}
|
|
441
|
+
case 'join': {
|
|
442
|
+
if (get().member) throw new Error('Leave the current team before joining another.');
|
|
378
443
|
if (!a || !b || rest.length) throw new Error('Usage: /team join <team> <alias>');
|
|
379
|
-
member = await box.join(a, b,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
444
|
+
const member = await box.join(a, b, context.sessionManager.getSessionId(), context.cwd);
|
|
445
|
+
set(() => ({ member, ...MEMBERSHIP_RESET }));
|
|
446
|
+
persist(); start();
|
|
447
|
+
ui.notify(`Joined ${a} as ${b}. Requests can start model turns automatically. /team pause to stop receiving work.`, 'info'); break;
|
|
448
|
+
}
|
|
449
|
+
case 'list': {
|
|
450
|
+
const teamNames = await box.teams();
|
|
451
|
+
set(() => ({ teamNames }));
|
|
452
|
+
ui.notify(teamNames.join('\n') || 'No teams. Use /team create <team>.', 'info'); break;
|
|
453
|
+
}
|
|
384
454
|
case 'members':
|
|
385
|
-
|
|
455
|
+
ui.notify((await box.members(required())).map(m => `${m.alias} · ${m.status} · ${m.cwd}`).join('\n'), 'info'); break;
|
|
386
456
|
case 'status': {
|
|
387
457
|
const snap = await box.snapshot(required());
|
|
388
458
|
const lines = flowLines(snap);
|
|
389
|
-
|
|
459
|
+
ui.notify(lines.length
|
|
390
460
|
? `Request flow (requester → assignee):\n${lines.join('\n')}`
|
|
391
461
|
: 'No unresolved team requests.', 'info');
|
|
392
462
|
break;
|
|
@@ -397,23 +467,25 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
397
467
|
const body = custom ? `${TEAM_CHECK_IN}\n\nSender's message: ${custom}` : TEAM_CHECK_IN;
|
|
398
468
|
const teammates = (await box.members(current)).filter(peer => peer.alias !== current.alias);
|
|
399
469
|
if (!teammates.length) {
|
|
400
|
-
|
|
470
|
+
ui.notify('No teammates to check in with.', 'info');
|
|
401
471
|
break;
|
|
402
472
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
473
|
+
// Sequential: each send is a mailbox transaction, and ordering
|
|
474
|
+
// keeps the queued check-ins in teammate order.
|
|
475
|
+
const outcomes = await teammates.reduce(async (previous, teammate) => {
|
|
476
|
+
const done = await previous;
|
|
406
477
|
try {
|
|
407
478
|
await send({ to: teammate.alias, kind: 'request', subject: 'Team check-in', body }, true);
|
|
408
|
-
|
|
479
|
+
return [...done, { alias: teammate.alias, error: undefined as string | undefined }];
|
|
409
480
|
} catch (error) {
|
|
410
|
-
|
|
411
|
-
failures.push(`${teammate.alias}: ${reason}`);
|
|
481
|
+
return [...done, { alias: teammate.alias, error: reason(error) }];
|
|
412
482
|
}
|
|
413
|
-
}
|
|
483
|
+
}, Promise.resolve([] as { alias: string; error?: string }[]));
|
|
484
|
+
const failures = outcomes.filter(item => item.error);
|
|
485
|
+
const queued = outcomes.length - failures.length;
|
|
414
486
|
const summary = `Queued team check-in for ${queued} teammate${queued === 1 ? '' : 's'}.`;
|
|
415
|
-
if (failures.length)
|
|
416
|
-
else
|
|
487
|
+
if (failures.length) ui.notify(`${summary}\nNot queued:\n${failures.map(item => `${item.alias}: ${item.error}`).join('\n')}`, 'warning');
|
|
488
|
+
else ui.notify(summary, 'info');
|
|
417
489
|
break;
|
|
418
490
|
}
|
|
419
491
|
case 'send': case 'note': {
|
|
@@ -425,17 +497,26 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
425
497
|
case 'inbox':
|
|
426
498
|
for (const message of (await box.history(required())).slice(-20)) pi.appendEntry('team-event', message);
|
|
427
499
|
break;
|
|
428
|
-
case 'pause':
|
|
500
|
+
case 'pause':
|
|
501
|
+
required();
|
|
502
|
+
set(() => ({ paused: true }));
|
|
503
|
+
persist();
|
|
504
|
+
ui.notify('Team reception paused. Current work is not cancelled.', 'info'); break;
|
|
429
505
|
case 'resume':
|
|
430
506
|
required();
|
|
431
|
-
if (active &&
|
|
432
|
-
paused
|
|
507
|
+
if (get().active && context.isIdle()) throw new Error('A result was not persisted. Leave and rejoin to recover; review before retrying work.');
|
|
508
|
+
set(() => ({ paused: false, budget: 0, lastError: '', quietReviews: 0 }));
|
|
509
|
+
persist(); enqueueTick(); break;
|
|
433
510
|
case 'leave':
|
|
434
|
-
required();
|
|
435
|
-
|
|
436
|
-
|
|
511
|
+
required();
|
|
512
|
+
set(() => ({ paused: true }));
|
|
513
|
+
if (get().active && !context.isIdle()) {
|
|
514
|
+
set(() => ({ leaving: true }));
|
|
515
|
+
pi.appendEntry('team-membership', null);
|
|
516
|
+
ui.notify('Will leave after reporting current work. No further messages will be processed.', 'info');
|
|
517
|
+
} else { await detach(); }
|
|
437
518
|
break;
|
|
438
|
-
default:
|
|
519
|
+
default: ui.notify(HELP, 'info');
|
|
439
520
|
}
|
|
440
521
|
} catch (error) { notice(error); }
|
|
441
522
|
});
|
|
@@ -444,8 +525,12 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
444
525
|
|
|
445
526
|
pi.on('session_start', async (event, context) => {
|
|
446
527
|
if (context.mode !== 'tui') return;
|
|
447
|
-
|
|
448
|
-
|
|
528
|
+
set(session => ({
|
|
529
|
+
ctx: context, closed: false, compacting: false, needsCompaction: false,
|
|
530
|
+
compactionSubject: '', compactionGeneration: session.compactionGeneration + 1,
|
|
531
|
+
}));
|
|
532
|
+
const teamNames = await box.teams();
|
|
533
|
+
set(() => ({ teamNames }));
|
|
449
534
|
// Only restore this exact session, never a fork's copied membership.
|
|
450
535
|
const saved = context.sessionManager.getBranch().filter(e => e.type === 'custom' && e.customType === 'team-membership').at(-1);
|
|
451
536
|
const data = saved?.type === 'custom' ? saved.data as {
|
|
@@ -453,39 +538,64 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
453
538
|
} | null : null;
|
|
454
539
|
if (data?.team && data.alias && data.session === context.sessionManager.getSessionId() && event.reason !== 'fork' && event.reason !== 'new') {
|
|
455
540
|
try {
|
|
456
|
-
member = await box.join(data.team, data.alias, data.session, context.cwd);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
541
|
+
const member = await box.join(data.team, data.alias, data.session, context.cwd);
|
|
542
|
+
const needsCompaction = data.needsCompaction ?? false;
|
|
543
|
+
set(() => ({
|
|
544
|
+
member,
|
|
545
|
+
paused: data.paused ?? false,
|
|
546
|
+
needsCompaction,
|
|
547
|
+
compactionSubject: needsCompaction ? data.compactionSubject ?? 'restored team task' : '',
|
|
548
|
+
}));
|
|
460
549
|
persist(); start();
|
|
461
550
|
} catch (error) { notice(error); }
|
|
462
551
|
}
|
|
463
552
|
});
|
|
464
|
-
pi.on('before_agent_start', event =>
|
|
465
|
-
|
|
466
|
-
|
|
553
|
+
pi.on('before_agent_start', event => {
|
|
554
|
+
const { member } = get();
|
|
555
|
+
return member ? { systemPrompt: `${event.systemPrompt}\n\n${PEER_RULES}\nJoined team: ${member.team}; your alias: ${member.alias}.` } : undefined;
|
|
556
|
+
});
|
|
557
|
+
pi.on('ui_prompt_start', () => { set(session => ({ prompts: session.prompts + 1 })); });
|
|
558
|
+
pi.on('ui_prompt_end', () => {
|
|
559
|
+
set(session => ({ prompts: Math.max(0, session.prompts - 1) }));
|
|
560
|
+
enqueueTick();
|
|
561
|
+
});
|
|
467
562
|
pi.on('input', event => {
|
|
468
563
|
if (event.source !== 'interactive') return;
|
|
469
|
-
budget
|
|
470
|
-
if (active) {
|
|
564
|
+
set(() => ({ budget: 0 }));
|
|
565
|
+
if (get().active) {
|
|
566
|
+
set(() => ({ userTakeover: true, paused: true }));
|
|
567
|
+
persist();
|
|
568
|
+
}
|
|
471
569
|
});
|
|
472
570
|
pi.on('tool_result', (event, context) => {
|
|
571
|
+
const { active, userTakeover } = get();
|
|
473
572
|
if (active && !userTakeover && !event.isError && ['edit', 'write'].includes(event.toolName) && typeof event.input.path === 'string') {
|
|
474
|
-
|
|
573
|
+
const path = resolve(context.cwd, event.input.path.replace(/^@/, ''));
|
|
574
|
+
set(session => ({ files: new Set(session.files).add(path) }));
|
|
475
575
|
}
|
|
476
576
|
});
|
|
477
577
|
pi.on('message_end', event => {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
578
|
+
const message = event.message;
|
|
579
|
+
if (!get().active || message.role !== 'assistant') return;
|
|
580
|
+
// Narrowing must happen before the update closure: the callback is not
|
|
581
|
+
// evaluated in this control-flow branch as far as the compiler is concerned.
|
|
582
|
+
const finalText = message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
583
|
+
const outcome: Result['outcome'] =
|
|
584
|
+
message.stopReason === 'aborted' ? 'interrupted' : message.stopReason === 'error' ? 'failed' : 'completed';
|
|
585
|
+
set(() => ({ finalText, outcome }));
|
|
481
586
|
});
|
|
482
587
|
pi.on('agent_settled', async (_event, context) => {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (!member || !active) return;
|
|
588
|
+
const shouldCompact = await queue(async () => {
|
|
589
|
+
const { member, active } = get();
|
|
590
|
+
if (!member || !active) return false;
|
|
486
591
|
const finished = active;
|
|
487
|
-
|
|
488
|
-
|
|
592
|
+
// Read the latest takeover flag: an interactive prompt can land while
|
|
593
|
+
// this handler waits behind the serial queue.
|
|
594
|
+
const takenOver = get().userTakeover;
|
|
595
|
+
if (takenOver) {
|
|
596
|
+
set(() => ({ outcome: 'interrupted', finalText: 'User took over the session. Subsequent output was not forwarded. Review before continuing.' }));
|
|
597
|
+
}
|
|
598
|
+
const { outcome, finalText, files } = get();
|
|
489
599
|
const report: Result = { outcome, body: finalText.slice(0, 3000) || `Agent turn ${outcome}; no final text. Review the recipient session.`, files: [], tests: [] };
|
|
490
600
|
for (const file of files) {
|
|
491
601
|
if (report.files.length >= 50 || file.length > 4096 || Buffer.byteLength(JSON.stringify({ ...report, files: [...report.files, file] })) > 31000) {
|
|
@@ -495,32 +605,34 @@ export function installTeam(pi: ExtensionAPI, options: { root?: string; pollMs?:
|
|
|
495
605
|
report.files.push(file);
|
|
496
606
|
}
|
|
497
607
|
await box.complete(member, finished.id, report);
|
|
498
|
-
active
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}).catch(error => { paused = true; notice(error); });
|
|
608
|
+
set(() => ({ active: undefined, ...(outcome !== 'completed' ? { paused: true } : {}) }));
|
|
609
|
+
if (get().leaving) { await detach(); return false; }
|
|
610
|
+
persist();
|
|
611
|
+
// Result persistence is the task boundary. Compact both executed
|
|
612
|
+
// requests and result-review turns before accepting another peer turn.
|
|
613
|
+
if (takenOver) return false;
|
|
614
|
+
set(() => ({ needsCompaction: true, compactionSubject: finished.subject }));
|
|
615
|
+
persist();
|
|
616
|
+
return true;
|
|
617
|
+
}).catch(error => {
|
|
618
|
+
set(() => ({ paused: true }));
|
|
619
|
+
notice(error);
|
|
620
|
+
return false;
|
|
621
|
+
});
|
|
513
622
|
if (shouldCompact) compactPendingContext(context);
|
|
514
623
|
enqueueTick();
|
|
515
624
|
});
|
|
516
625
|
pi.on('session_shutdown', async () => {
|
|
517
|
-
closed
|
|
626
|
+
set(session => ({ closed: true, compacting: false, compactionGeneration: session.compactionGeneration + 1 }));
|
|
627
|
+
stop();
|
|
518
628
|
await queue(async () => {
|
|
629
|
+
const { member, active, leaving } = get();
|
|
519
630
|
if (member) {
|
|
520
|
-
if (active && !leaving) { paused
|
|
631
|
+
if (active && !leaving) { set(() => ({ paused: true })); persist(); }
|
|
521
632
|
await box.leave(member).catch(notice);
|
|
522
633
|
}
|
|
523
|
-
member
|
|
634
|
+
set(() => ({ member: undefined, active: undefined }));
|
|
635
|
+
get().ctx?.ui.setWidget('team', undefined);
|
|
524
636
|
});
|
|
525
637
|
});
|
|
526
638
|
}
|
package/src/mailbox.ts
CHANGED
|
@@ -21,6 +21,13 @@ type Presence = { token: string; status: 'idle' | 'busy' | 'paused'; seen: numbe
|
|
|
21
21
|
export const LEASE_MS = 30_000;
|
|
22
22
|
const MAX_BYTES = 32_000_000;
|
|
23
23
|
const MAX_ATTEMPTS = 100;
|
|
24
|
+
/** Preallocated attempt sequence: a retry counter without a mutable binding. */
|
|
25
|
+
const ATTEMPTS = Array.from({ length: MAX_ATTEMPTS }, (_, index) => index);
|
|
26
|
+
|
|
27
|
+
function parseMailbox(raw: string): unknown {
|
|
28
|
+
try { return JSON.parse(raw); }
|
|
29
|
+
catch { throw new Error('Invalid mailbox format; preserved for manual recovery'); }
|
|
30
|
+
}
|
|
24
31
|
|
|
25
32
|
export function identifier(value: string): string {
|
|
26
33
|
if (!/^[a-z][a-z0-9-]{0,47}$/.test(value)) {
|
|
@@ -54,22 +61,23 @@ export class Mailbox {
|
|
|
54
61
|
return join(this.path(team), 'presence', `${identifier(alias)}.json`);
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/** Parse either an envelope record or a pre-envelope mailbox. */
|
|
65
|
+
private parse(raw: string): StoreRecord<State> {
|
|
66
|
+
const parsed = parseMailbox(raw);
|
|
67
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
|
|
68
|
+
(parsed as { schemaVersion?: unknown }).schemaVersion === 1) {
|
|
69
|
+
return envelope<State>(raw);
|
|
70
|
+
}
|
|
71
|
+
// Legacy mailbox without an envelope: accepted once as revision 0 and
|
|
72
|
+
// rewritten as an envelope record on the next publication.
|
|
73
|
+
if (!Value.Check(StateSchema, parsed)) throw new Error('Invalid mailbox format; preserved for manual recovery');
|
|
74
|
+
return { revision: 0, payload: parsed as State };
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
/** Envelope records plus transparent migration of pre-envelope mailboxes. */
|
|
58
78
|
private normalize(team: string) {
|
|
59
79
|
return (raw: string): StoreRecord<State> => {
|
|
60
|
-
|
|
61
|
-
try { parsed = JSON.parse(raw); }
|
|
62
|
-
catch { throw new Error('Invalid mailbox format; preserved for manual recovery'); }
|
|
63
|
-
let record: StoreRecord<State>;
|
|
64
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
|
|
65
|
-
(parsed as { schemaVersion?: unknown }).schemaVersion === 1) {
|
|
66
|
-
record = envelope<State>(raw);
|
|
67
|
-
} else {
|
|
68
|
-
// Legacy mailbox without an envelope: accepted once as revision 0 and
|
|
69
|
-
// rewritten as an envelope record on the next publication.
|
|
70
|
-
if (!Value.Check(StateSchema, parsed)) throw new Error('Invalid mailbox format; preserved for manual recovery');
|
|
71
|
-
record = { revision: 0, payload: parsed as State };
|
|
72
|
-
}
|
|
80
|
+
const record = this.parse(raw);
|
|
73
81
|
const state = record.payload;
|
|
74
82
|
if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; preserved for manual recovery');
|
|
75
83
|
if (state.members.some(m => m.team !== team) || state.messages.some(m => m.team !== team)) {
|
|
@@ -89,23 +97,21 @@ export class Mailbox {
|
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
private async readPresence(team: string): Promise<Map<string, Presence>> {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
catch { return map; }
|
|
96
|
-
await Promise.all(names.map(async name => {
|
|
97
|
-
if (!/^[a-z][a-z0-9-]{0,47}\.json$/.test(name)) return;
|
|
100
|
+
const names = await readdir(join(this.path(team), 'presence')).catch(() => [] as string[]);
|
|
101
|
+
const entries = await Promise.all(names.map(async (name): Promise<[string, Presence] | undefined> => {
|
|
102
|
+
if (!/^[a-z][a-z0-9-]{0,47}\.json$/.test(name)) return undefined;
|
|
98
103
|
try {
|
|
99
104
|
const raw = await readFile(join(this.path(team), 'presence', name), 'utf8');
|
|
100
|
-
if (raw.length > 4096) return;
|
|
105
|
+
if (raw.length > 4096) return undefined;
|
|
101
106
|
const presence = JSON.parse(raw) as Presence;
|
|
102
107
|
if (typeof presence?.seen === 'number' && typeof presence?.token === 'string' &&
|
|
103
108
|
['idle', 'busy', 'paused'].includes(presence?.status)) {
|
|
104
|
-
|
|
109
|
+
return [name.slice(0, -'.json'.length), presence];
|
|
105
110
|
}
|
|
106
111
|
} catch { /* A presence file may be replaced or removed mid-read. */ }
|
|
112
|
+
return undefined;
|
|
107
113
|
}));
|
|
108
|
-
return
|
|
114
|
+
return new Map(entries.filter(entry => !!entry));
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
private alive(member: Member, presence: Map<string, Presence>): boolean {
|
|
@@ -132,24 +138,19 @@ export class Mailbox {
|
|
|
132
138
|
try { await lstat(this.path(team)); }
|
|
133
139
|
catch { throw new Error(`Unknown team "${team}". Use /team list or /team create.`); }
|
|
134
140
|
await privateDirectory(this.path(team));
|
|
135
|
-
for (
|
|
141
|
+
for (const attempt of ATTEMPTS) {
|
|
136
142
|
const record = await this.readState(team);
|
|
137
143
|
const state = record.payload;
|
|
138
144
|
const before = JSON.stringify(state);
|
|
139
145
|
const presence = await this.readPresence(team);
|
|
140
|
-
const swept
|
|
141
|
-
for (const member of state
|
|
142
|
-
if (member.status !== 'offline' && !this.alive(member, presence)) {
|
|
143
|
-
this.disconnect(state, member);
|
|
144
|
-
swept.push(member.alias);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
146
|
+
const swept = state.members.filter(member => member.status !== 'offline' && !this.alive(member, presence));
|
|
147
|
+
for (const member of swept) this.disconnect(state, member);
|
|
147
148
|
const result = action(state);
|
|
148
149
|
if (before === JSON.stringify(state)) return result;
|
|
149
150
|
if (!Value.Check(StateSchema, state)) throw new Error('Invalid mailbox format; refusing to write');
|
|
150
151
|
try {
|
|
151
152
|
await publish(this.recordPath(team), record.revision, state, this.normalize(team), { maxBytes: MAX_BYTES });
|
|
152
|
-
await Promise.all(swept.map(
|
|
153
|
+
await Promise.all(swept.map(member => unlink(this.presencePath(team, member.alias)).catch(() => {})));
|
|
153
154
|
return result;
|
|
154
155
|
} catch (error) {
|
|
155
156
|
const code = (error as { code?: string }).code;
|
package/src/store.ts
CHANGED
|
@@ -47,14 +47,19 @@ function assertSafeFile(path: string, info: { isFile(): boolean; size: number; m
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
try { handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
50
|
+
/** Resolve to `undefined` when the target is absent; other errors propagate. */
|
|
51
|
+
async function absentAsUndefined<T>(work: Promise<T>): Promise<T | undefined> {
|
|
52
|
+
try { return await work; }
|
|
54
53
|
catch (error) {
|
|
55
54
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
56
55
|
throw error;
|
|
57
56
|
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Lock-free read. Missing records stay missing; corrupt records throw. */
|
|
60
|
+
export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
|
|
61
|
+
const handle = await absentAsUndefined(open(path, constants.O_RDONLY | constants.O_NOFOLLOW));
|
|
62
|
+
if (!handle) return undefined;
|
|
58
63
|
try {
|
|
59
64
|
assertSafeFile(path, await handle.stat(), maxBytes);
|
|
60
65
|
return normalize(await handle.readFile('utf8'));
|
|
@@ -67,12 +72,8 @@ export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBy
|
|
|
67
72
|
const cache = new Map<string, { ino: number; size: number; mtimeMs: number; record: Record<unknown> | undefined }>();
|
|
68
73
|
|
|
69
74
|
export async function readRecordCached<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
catch (error) {
|
|
73
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { cache.delete(path); return undefined; }
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
75
|
+
const info = await absentAsUndefined(stat(path));
|
|
76
|
+
if (!info) { cache.delete(path); return undefined; }
|
|
76
77
|
const hit = cache.get(path);
|
|
77
78
|
if (hit && hit.ino === info.ino && hit.size === info.size && hit.mtimeMs === info.mtimeMs) {
|
|
78
79
|
return hit.record as Record<T> | undefined;
|
|
@@ -103,25 +104,28 @@ export async function writeAtomic(path: string, text: string, durability: Durabi
|
|
|
103
104
|
} finally { await unlink(tmp).catch(() => {}); }
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const info = await stat(lockPath).catch(() => undefined);
|
|
115
|
-
if (info && Date.now() - info.mtimeMs > STALE_LOCK_MS) {
|
|
116
|
-
await unlink(lockPath).catch(() => {});
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
throw Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
|
|
121
|
-
}
|
|
107
|
+
const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
|
|
108
|
+
|
|
109
|
+
/** Resolve to `undefined` when the lock is already held; other errors propagate. */
|
|
110
|
+
async function tryLock(lockPath: string) {
|
|
111
|
+
try { return await open(lockPath, 'wx', 0o600); }
|
|
112
|
+
catch (error) {
|
|
113
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined;
|
|
114
|
+
throw error;
|
|
122
115
|
}
|
|
123
116
|
}
|
|
124
117
|
|
|
118
|
+
async function acquireLock(lockPath: string) {
|
|
119
|
+
const held = await tryLock(lockPath);
|
|
120
|
+
if (held) return held;
|
|
121
|
+
// A crashed writer can leave its lock behind; publication takes
|
|
122
|
+
// microseconds, so a lock older than STALE_LOCK_MS is safe to break.
|
|
123
|
+
const info = await stat(lockPath).catch(() => undefined);
|
|
124
|
+
if (!info || Date.now() - info.mtimeMs <= STALE_LOCK_MS) throw locked();
|
|
125
|
+
await unlink(lockPath).catch(() => {});
|
|
126
|
+
return await tryLock(lockPath) ?? (() => { throw locked(); })();
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
async function pruneRevisions(dir: string, latest: number): Promise<void> {
|
|
126
130
|
const revisionsDir = join(dir, 'revisions');
|
|
127
131
|
const names = await readdir(revisionsDir).catch(() => [] as string[]);
|