@shardflux/cli 0.16.0 → 0.17.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +43 -5
  3. package/dist/agent-commands.d.ts +9 -1
  4. package/dist/agent-commands.js +65 -16
  5. package/dist/agent-models.d.ts +61 -0
  6. package/dist/agent-models.js +79 -0
  7. package/dist/agent-state.d.ts +10 -0
  8. package/dist/agent-state.js +13 -0
  9. package/dist/agent-target-commands.d.ts +6 -1
  10. package/dist/agent-target-commands.js +117 -16
  11. package/dist/agent-targets.d.ts +2 -0
  12. package/dist/agent-targets.js +10 -4
  13. package/dist/args.d.ts +5 -1
  14. package/dist/args.js +49 -9
  15. package/dist/cli-proxy-forward.d.ts +45 -0
  16. package/dist/cli-proxy-forward.js +432 -0
  17. package/dist/cli-proxy-hub.d.ts +273 -0
  18. package/dist/cli-proxy-hub.js +749 -0
  19. package/dist/cli-proxy.d.ts +325 -0
  20. package/dist/cli-proxy.js +1016 -0
  21. package/dist/codex-cloud.d.ts +2 -1
  22. package/dist/codex-cloud.js +16 -6
  23. package/dist/command-center-actions.js +16 -1
  24. package/dist/conversation-local.js +2 -2
  25. package/dist/conversation-stash.js +4 -3
  26. package/dist/conversation-terminal.d.ts +5 -0
  27. package/dist/conversation-terminal.js +45 -6
  28. package/dist/conversations.d.ts +18 -2
  29. package/dist/conversations.js +12 -5
  30. package/dist/device-approvals.d.ts +12 -0
  31. package/dist/device-approvals.js +73 -0
  32. package/dist/device-helper.d.ts +3 -1
  33. package/dist/device-helper.js +5 -2
  34. package/dist/errors.d.ts +2 -0
  35. package/dist/errors.js +1 -1
  36. package/dist/handlers.js +3 -1
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.js +3 -0
  39. package/dist/linux-helper.d.ts +12 -0
  40. package/dist/linux-helper.js +88 -0
  41. package/dist/linux-terminal.d.ts +11 -0
  42. package/dist/linux-terminal.js +42 -0
  43. package/dist/main.js +29 -6
  44. package/dist/onboarding.d.ts +2 -0
  45. package/dist/onboarding.js +53 -5
  46. package/dist/t3-launch.d.ts +37 -0
  47. package/dist/t3-launch.js +133 -0
  48. package/dist/t3-mode.d.ts +121 -0
  49. package/dist/t3-mode.js +654 -0
  50. package/dist/t3-phone.d.ts +17 -0
  51. package/dist/t3-phone.js +15 -0
  52. package/dist/t3-thread-script.d.ts +18 -0
  53. package/dist/t3-thread-script.js +245 -0
  54. package/dist/terminal-onboarding.d.ts +11 -0
  55. package/dist/terminal-onboarding.js +75 -0
  56. package/dist/thread-on-computer.d.ts +2 -2
  57. package/dist/thread-on-computer.js +13 -7
  58. package/dist/update.d.ts +1 -1
  59. package/dist/update.js +12 -1
  60. package/dist/worktree-twin-engine.d.ts +31 -1
  61. package/dist/worktree-twin-engine.js +253 -40
  62. package/dist/worktree-twin.d.ts +17 -0
  63. package/dist/worktree-twin.js +147 -22
  64. package/package.json +3 -3
@@ -14,7 +14,7 @@ import { conversationClient } from "./conversation-commands.js";
14
14
  import { conversationName, conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
15
15
  import { UsageError } from "./errors.js";
16
16
  import { projectIdentity } from "./project-identity.js";
17
- import { TwinLedger, backupRoot, inspectWorktree, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
17
+ import { TwinLedger, backupRoot, inspectWorktree, landingBatch, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
18
18
  import { readdirSync, readFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
  /** Its VM is gone: the conversation ended (archived after the retention window, or done). */
@@ -51,41 +51,165 @@ export function conflictMessage(into, files) {
51
51
  + `Resolve each conflict without asking: where you and the user changed the same lines, keep the user's lines and keep your change only where it fits around them; then run the checks and commit. `
52
52
  + `Your work lands on the user's computer automatically when you finish. Ask the user only if the result cannot work either way.`;
53
53
  }
54
+ /** Refused by an API without turn waits (POST /wait with turn_after, 422): the twin polls instead. */
55
+ export class TurnWaitUnsupported extends Error {
56
+ }
57
+ /** The full pass's interval when finished turns are pushed (a safety net), and when they are not. */
58
+ const PUSHED_PASS_MS = 15_000, POLLED_PASS_MS = 1000;
54
59
  export class TwinEngine {
55
60
  #deps;
56
61
  #reported = new Map();
57
62
  #posted = new Map();
63
+ #watchers = new Map();
64
+ /** Conversations whose turn just ended (pushed by a turn wait), landed by the next pass without listing anything. */
65
+ #pushed = new Map();
66
+ #wake;
67
+ #pushes;
68
+ #nextFull = -Infinity;
58
69
  constructor(deps) { this.#deps = deps; }
59
70
  #report(key, line) { if (this.#reported.get(key) !== line) {
60
71
  this.#reported.set(key, line);
61
72
  this.#deps.log(line);
62
73
  } }
63
- /** One pass over every project with a local conversation; returns the suggested wait before the next. */
74
+ /** Resolves when a pushed turn is waiting to land (the loop's sleep ends at once). */
75
+ pushed() {
76
+ if (this.#pushed.size)
77
+ return Promise.resolve();
78
+ return new Promise(resolve => { this.#wake = resolve; });
79
+ }
80
+ /** Stops every turn wait. */
81
+ stop() { for (const w of this.#watchers.values())
82
+ w.abort(); this.#watchers.clear(); }
83
+ /**
84
+ * One pass; returns the suggested wait before the next. Turns pushed by the waits land first, without a listing;
85
+ * otherwise (and at least every 15 s, or every second when the API has no turn waits) every project is listed.
86
+ */
64
87
  async tick(signal) {
65
- const d = this.#deps, records = d.records().filter(r => !r.handed_off);
66
- let wait = 1000;
88
+ const d = this.#deps;
89
+ if (this.#pushed.size) {
90
+ const pushed = [...this.#pushed.values()];
91
+ this.#pushed.clear();
92
+ const records = new Map(d.records().filter(r => !r.handed_off).map(r => [r.conversation.id, r]));
93
+ const ready = [];
94
+ for (const { c, at } of pushed) {
95
+ const record = records.get(c.id), mode = record ? d.ledger.mode(record.project_id) : 'off';
96
+ if (!record || mode === 'off' || !eligible(c, d.ledger, d.now()))
97
+ continue;
98
+ ready.push({ c, record, mode, agent: { id: '', workspace_id: c.workspace_id, state: 'waiting', since: new Date(at).toISOString() }, at });
99
+ }
100
+ const wait = await this.#land(ready, signal);
101
+ // A landing that has to wait (quiet period, a Git operation) is retried by a full pass at that time.
102
+ if (wait < POLLED_PASS_MS)
103
+ this.#nextFull = Math.min(this.#nextFull, d.now() + wait);
104
+ return this.#pushed.size ? 0 : Math.max(0, Math.min(wait, this.#nextFull - d.now()));
105
+ }
106
+ const interval = this.#pushes ? PUSHED_PASS_MS : POLLED_PASS_MS;
107
+ if (d.now() < this.#nextFull)
108
+ return this.#nextFull - d.now();
109
+ const records = d.records().filter(r => !r.handed_off);
110
+ let wait = interval;
111
+ const watched = new Set();
67
112
  for (const project of new Set(records.map(r => r.project_id))) {
68
113
  signal?.throwIfAborted();
69
114
  const mode = d.ledger.mode(project);
70
115
  if (mode === 'off')
71
116
  continue;
72
117
  const mine = new Map(records.filter(r => r.project_id === project).map(r => [r.conversation.id, r]));
73
- const candidates = (await d.conversations(project)).filter(c => mine.has(c.id) && eligible(c, d.ledger, d.now()));
118
+ const listedAt = d.now();
119
+ const all = (await d.conversations(project)).filter(c => mine.has(c.id));
120
+ for (const c of all)
121
+ if (!ended(c) && c.route !== 'codex-cloud' && c.join_mode !== 'reviewer') {
122
+ watched.add(c.id);
123
+ this.#watch(c, listedAt, signal);
124
+ }
125
+ const candidates = all.filter(c => eligible(c, d.ledger, d.now()));
74
126
  if (!candidates.length)
75
127
  continue;
76
128
  const agents = candidates.some(c => !ended(c)) ? await d.agents(project) : [];
77
- // In the order the turns finished (several may have finished while this computer was away); each lands against
78
- // the checkout as it is when its turn comes.
79
- const ready = candidates.map(c => ({ c, turn: finished(c, agents) })).filter((x) => x.turn !== null)
80
- .map(x => ({ ...x, at: x.turn === 'state' ? Date.parse(x.c.last_activity_at ?? x.c.created_at) : Date.parse(x.turn.since) }))
81
- .sort((a, b) => (a.at || 0) - (b.at || 0));
82
- for (const { c, turn } of ready) {
83
- const next = await this.landOne(c, mine.get(c.id), mode, turn === 'state' ? null : turn, signal);
84
- if (next !== undefined)
85
- wait = Math.min(wait, next);
129
+ const ready = [];
130
+ for (const c of candidates) {
131
+ const turn = finished(c, agents);
132
+ if (!turn)
133
+ continue;
134
+ ready.push({ c, record: mine.get(c.id), mode, agent: turn === 'state' ? null : turn,
135
+ at: turn === 'state' ? Date.parse(c.last_activity_at ?? c.created_at) : Date.parse(turn.since) });
86
136
  }
137
+ wait = Math.min(wait, await this.#land(ready, signal));
87
138
  }
88
- return wait;
139
+ for (const [id, w] of this.#watchers)
140
+ if (!watched.has(id)) {
141
+ w.abort();
142
+ this.#watchers.delete(id);
143
+ }
144
+ this.#nextFull = d.now() + Math.min(wait, interval);
145
+ return this.#pushed.size ? 0 : Math.min(wait, interval);
146
+ }
147
+ /**
148
+ * In the order the turns finished (several may have finished at once, or while this computer was away), each
149
+ * against the checkout as it is when its turn comes; finished conversations for the same branch land together.
150
+ */
151
+ async #land(ready, signal) {
152
+ let wait = Infinity;
153
+ ready.sort((a, b) => (a.at || 0) - (b.at || 0));
154
+ const key = (r) => `${r.record.folder}\0${r.c.base_branch ?? r.record.base_branch ?? ''}`;
155
+ for (let i = 0; i < ready.length;) {
156
+ let j = i + 1;
157
+ while (j < ready.length && key(ready[j]) === key(ready[i]))
158
+ j++;
159
+ const group = ready.slice(i, j);
160
+ i = j;
161
+ const batchable = group.filter(r => r.mode === 'auto' && !ended(r.c));
162
+ const next = batchable.length > 1 ? await this.landBatch(batchable, signal) : undefined;
163
+ for (const r of group)
164
+ if (batchable.length <= 1 || !batchable.includes(r)) {
165
+ const one = await this.landOne(r.c, r.record, r.mode, r.agent, signal);
166
+ if (one !== undefined)
167
+ wait = Math.min(wait, one);
168
+ }
169
+ if (next !== undefined)
170
+ wait = Math.min(wait, next);
171
+ }
172
+ return wait === Infinity ? POLLED_PASS_MS : wait;
173
+ }
174
+ /** One long-poll per active conversation: its next finished turn is pushed to the loop. */
175
+ #watch(c, listedAt, signal) {
176
+ const d = this.#deps;
177
+ if (!d.waitTurn || this.#pushes === false || this.#watchers.has(c.id))
178
+ return;
179
+ const abort = new AbortController();
180
+ this.#watchers.set(c.id, abort);
181
+ const stop = AbortSignal.any([abort.signal, ...(signal ? [signal] : [])]);
182
+ // A turn that ended just before the listing is caught by the listing itself; the overlap only repeats it.
183
+ let after = new Date(listedAt - 2000).toISOString(), failures = 0;
184
+ void (async () => {
185
+ while (!stop.aborted) {
186
+ try {
187
+ const turn = await d.waitTurn(c, after, stop);
188
+ this.#pushes = true;
189
+ failures = 0;
190
+ if (turn === 'ended') {
191
+ this.#watchers.delete(c.id);
192
+ return;
193
+ }
194
+ if (!turn)
195
+ continue;
196
+ after = turn.reported_at;
197
+ this.#pushed.set(c.id, { c: turn.conversation, at: Date.parse(turn.reported_at) });
198
+ this.#wake?.();
199
+ this.#wake = undefined;
200
+ }
201
+ catch (error) {
202
+ if (stop.aborted)
203
+ return;
204
+ if (error instanceof TurnWaitUnsupported) {
205
+ this.#pushes = false;
206
+ this.stop();
207
+ return;
208
+ }
209
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(failures++, 6)), undefined, { signal: stop }).catch(() => undefined);
210
+ }
211
+ }
212
+ })();
89
213
  }
90
214
  /** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
91
215
  resetBackoff() {
@@ -94,8 +218,10 @@ export class TwinEngine {
94
218
  st.retry_at = 0;
95
219
  } });
96
220
  this.#reported.clear();
221
+ this.#nextFull = -Infinity;
97
222
  }
98
- async landOne(c, record, mode, agent, signal) {
223
+ /** Everything a landing needs here, or undefined (nothing to do now; reported). */
224
+ async #prepare(c, record, agent) {
99
225
  const d = this.#deps, st = d.ledger.conversation(c.id), rev = revision(c);
100
226
  const into = c.base_branch ?? record.base_branch ?? st.base_branch;
101
227
  const name = c.name ?? conversationName(c);
@@ -118,15 +244,17 @@ export class TwinEngine {
118
244
  }
119
245
  return;
120
246
  }
121
- const branch = `refs/heads/${into}`;
122
- let worktree = null;
123
- const target = (await listWorktrees(record.folder)).find(w => w.branch === branch);
124
- if (target) {
125
- worktree = await inspectWorktree(target.root);
126
- if (pauseReason(worktree))
127
- return 1000;
128
- }
129
- const started = d.now();
247
+ const target = (await listWorktrees(record.folder)).find(w => w.branch === `refs/heads/${into}`);
248
+ const worktree = target ? await inspectWorktree(target.root) : null;
249
+ if (worktree && pauseReason(worktree))
250
+ return 1000;
251
+ return { c, record, agent, into, name, label, rev, st, target, worktree };
252
+ }
253
+ async landOne(c, record, mode, agent, signal) {
254
+ const d = this.#deps, started = d.now();
255
+ const p = await this.#prepare(c, record, agent);
256
+ if (p === undefined || typeof p === 'number')
257
+ return p;
130
258
  let vm, fromHub = false;
131
259
  try {
132
260
  vm = ended(c) ? null : await d.cell(c);
@@ -139,31 +267,92 @@ export class TwinEngine {
139
267
  catch (error) {
140
268
  if (signal?.aborted)
141
269
  throw error;
142
- return this.#fail(c, label, error instanceof Error ? error.message : String(error));
270
+ return this.#fail(c, p.label, error instanceof Error ? error.message : String(error));
143
271
  }
144
- let outcome, keep = false;
272
+ let outcome;
145
273
  try {
274
+ let worktree = p.worktree;
146
275
  for (let attempt = 0; attempt < 3; attempt++) {
147
- if (target && attempt)
148
- worktree = await inspectWorktree(target.root);
149
- outcome = await landingRound(vm.cell, { conversation: { id: c.id, name, title: c.title, summary: c.summary, branch: c.branch, base_commit: c.base_commit, worktree_path: c.worktree_path },
150
- into, mode: mode === 'pr' ? 'pr' : 'auto', device: d.device, repo: target?.root ?? record.folder, worktree, stateDir: d.stateDir, backupRoot: backupRoot(d.stateDir),
151
- upto: st.upto, known: [st.tip, st.upto].filter((x) => !!x), hub: d.hub, vmTmp: d.vmTmp, quietMs: d.quietMs, signal, now: () => d.now(),
276
+ if (p.target && attempt)
277
+ worktree = await inspectWorktree(p.target.root);
278
+ outcome = await landingRound(vm.cell, { ...this.#input(p, worktree, signal), mode: mode === 'pr' ? 'pr' : 'auto',
152
279
  ...(fromHub ? { fromHub: { ...(c.repo_remote ? { repoRemote: c.repo_remote } : {}) } } : {}) });
153
280
  if (outcome.state !== 'moved')
154
281
  break;
155
282
  }
156
- keep = outcome?.state === 'landed';
157
283
  }
158
284
  catch (error) {
285
+ vm.close();
159
286
  if (signal?.aborted)
160
287
  throw error;
161
- return this.#fail(c, label, error instanceof Error ? error.message : String(error));
288
+ return this.#fail(c, p.label, error instanceof Error ? error.message : String(error));
289
+ }
290
+ return this.#settle(p, outcome, vm, started, fromHub);
291
+ }
292
+ /** Several finished conversations for one branch: merged in their VMs at once, the files written once. */
293
+ async landBatch(items, signal) {
294
+ const d = this.#deps, started = d.now();
295
+ const prepared = [];
296
+ let wait;
297
+ for (const r of items) {
298
+ const p = await this.#prepare(r.c, r.record, r.agent);
299
+ if (typeof p === 'number')
300
+ wait = Math.min(wait ?? p, p);
301
+ else if (p)
302
+ prepared.push(p);
303
+ }
304
+ if (prepared.length < 2) {
305
+ for (const p of prepared) {
306
+ const one = await this.landOne(p.c, p.record, 'auto', p.agent, signal);
307
+ if (one !== undefined)
308
+ wait = Math.min(wait ?? one, one);
309
+ }
310
+ return wait;
162
311
  }
163
- finally {
164
- if (!keep)
312
+ const vms = await Promise.all(prepared.map(p => d.cell(p.c).catch((error) => error instanceof Error ? error : new Error(String(error)))));
313
+ const ok = prepared.flatMap((p, i) => { const vm = vms[i]; return vm && !(vm instanceof Error) ? [{ p, vm }] : []; });
314
+ for (const [i, vm] of vms.entries()) {
315
+ // Without its VM now, it lands on its own (from the hub, or after a retry).
316
+ if (!vm || vm instanceof Error) {
317
+ const one = await this.landOne(prepared[i].c, prepared[i].record, 'auto', prepared[i].agent, signal);
318
+ if (one !== undefined)
319
+ wait = Math.min(wait ?? one, one);
320
+ }
321
+ }
322
+ if (!ok.length)
323
+ return wait;
324
+ const first = ok[0].p;
325
+ let outcomes;
326
+ try {
327
+ outcomes = await landingBatch(ok.map(({ p, vm }) => ({ cell: vm.cell, conversation: this.#input(p, p.worktree, signal).conversation, upto: p.st.upto,
328
+ known: [p.st.tip, p.st.upto].filter((x) => !!x) })), { ...this.#input(first, first.worktree, signal) });
329
+ }
330
+ catch (error) {
331
+ for (const { vm } of ok)
165
332
  vm.close();
333
+ if (signal?.aborted)
334
+ throw error;
335
+ for (const { p } of ok)
336
+ wait = Math.min(wait ?? Infinity, this.#fail(p.c, p.label, error instanceof Error ? error.message : String(error)));
337
+ return wait;
338
+ }
339
+ for (const [i, { p, vm }] of ok.entries()) {
340
+ const next = await this.#settle(p, outcomes[i], vm, started, false);
341
+ if (next !== undefined)
342
+ wait = Math.min(wait ?? next, next);
166
343
  }
344
+ return wait;
345
+ }
346
+ #input(p, worktree, signal) {
347
+ const d = this.#deps, c = p.c;
348
+ return { conversation: { id: c.id, name: p.name, title: c.title, summary: c.summary, branch: c.branch, base_commit: c.base_commit, worktree_path: c.worktree_path },
349
+ into: p.into, mode: 'auto', device: d.device, repo: p.target?.root ?? p.record.folder, worktree, stateDir: d.stateDir, backupRoot: backupRoot(d.stateDir),
350
+ upto: p.st.upto, known: [p.st.tip, p.st.upto].filter((x) => !!x), hub: d.hub, vmTmp: d.vmTmp, quietMs: d.quietMs, signal, now: () => d.now() };
351
+ }
352
+ async #settle(p, o, vm, started, fromHub) {
353
+ const d = this.#deps, { c, into, label, rev, st, agent } = p;
354
+ if (o.state !== 'landed')
355
+ vm.close();
167
356
  const post = async (action, ref) => {
168
357
  // An ended conversation cannot record a landing any more (the API refuses); the ledger keeps it.
169
358
  if (ended(c))
@@ -171,7 +360,6 @@ export class TwinEngine {
171
360
  this.#posted.set(c.id, d.now());
172
361
  await d.land(c, action, ref);
173
362
  };
174
- const o = outcome;
175
363
  switch (o.state) {
176
364
  case 'landed': {
177
365
  // The VM records the landing and copies it to the project hub on its own time; the connection closes after.
@@ -181,7 +369,7 @@ export class TwinEngine {
181
369
  d.ledger.patch(c.id, { upto: o.land.upto, tip: o.land.tip, landed_revision: rev, landed_at: new Date(o.applied.at).toISOString(), method: o.land.method,
182
370
  failures: 0, retry_at: 0, last_error: undefined, ...(latency !== undefined ? { latency_ms: latency } : {}) });
183
371
  await post('merge', into);
184
- d.log(`Twin: landed ${label} into ${into} in ${target?.root ?? record.folder}${fromHub ? ' from the project hub' : ''} (${o.land.method}; ${o.applied.files.length} file${o.applied.files.length === 1 ? '' : 's'}`
372
+ d.log(`Twin: landed ${label} into ${into} in ${p.target?.root ?? p.record.folder}${fromHub ? ' from the project hub' : ''} (${o.land.method}; ${o.applied.files.length} file${o.applied.files.length === 1 ? '' : 's'}`
185
373
  + `${o.applied.raced.length ? `, kept your version of ${o.applied.raced.join(', ')}` : ''}${o.land.shadowed?.length ? `, your edits win in ${o.land.shadowed.join(', ')}` : ''}`
186
374
  + `${latency !== undefined ? `; ${(latency / 1000).toFixed(1)} s after the turn ended, round started at ${((started - Date.parse(agent.since)) / 1000).toFixed(1)} s` : ''}`
187
375
  + `; round ${Object.entries(o.timings).map(([k, v]) => `${k} ${v} ms`).join(', ')}; in the VM ${Object.entries(o.land.phases ?? {}).map(([k, v]) => `${k} ${v}`).join(', ')}).`);
@@ -207,6 +395,10 @@ export class TwinEngine {
207
395
  d.ledger.patch(c.id, { conflict_tip: o.land.conversation_tip, conflict_revision: rev });
208
396
  d.log(`Twin: ${label} conflicts with your commits on ${into} (${(o.land.conflicts ?? []).join(', ')}); its agent is resolving it.`);
209
397
  return;
398
+ // It lands next, against the state the others left.
399
+ case 'deferred':
400
+ this.#pushed.set(c.id, { c, at: agent ? Date.parse(agent.since) : d.now() });
401
+ return 0;
210
402
  case 'busy':
211
403
  d.ledger.patch(c.id, { retry_at: d.now() + (o.waitMs ?? 1000) });
212
404
  return o.waitMs ?? 1000;
@@ -257,7 +449,12 @@ export async function runTwinLoop(deps, signal) {
257
449
  let left = wait, probed = deps.mono();
258
450
  while (left > 0 && !signal.aborted) {
259
451
  const slice = Math.min(1000, left), wall = deps.wall(), mono = deps.mono();
260
- await deps.sleep(slice, signal);
452
+ let kicked = false;
453
+ const sleeping = new AbortController(), both = AbortSignal.any([signal, sleeping.signal]);
454
+ await Promise.race([deps.sleep(slice, both), ...(deps.kicked ? [deps.kicked().then(() => { kicked = true; })] : [])]);
455
+ sleeping.abort();
456
+ if (kicked)
457
+ break;
261
458
  left -= slice;
262
459
  const woke = deps.wall() - wall - (deps.mono() - mono) > JUMP_MS;
263
460
  const now = deps.network(), changed = now !== net;
@@ -353,13 +550,29 @@ export async function runWorktreeTwin(ctx, device, signal) {
353
550
  resume: async (c) => { await client().conversations.resume(c.id, { signal }); },
354
551
  land: async (c, action, into) => { await client().conversations.land(c.id, action, { into, signal }); },
355
552
  message: async (c, text) => { await client().conversations.send(c.id, text, { signal }); },
553
+ waitTurn: async (c, after, s) => {
554
+ let r;
555
+ try {
556
+ r = await client().conversations.wait(c.id, { until: ['archived'], timeoutSeconds: 300, turnAfter: after, signal: s });
557
+ }
558
+ catch (error) {
559
+ if (error.status === 422)
560
+ throw new TurnWaitUnsupported('the API has no turn waits');
561
+ throw error;
562
+ }
563
+ if ('timeout' in r)
564
+ return null;
565
+ if (r.event.kind !== 'turn_ended' || !r.event.reported_at)
566
+ return 'ended';
567
+ return { conversation: r.conversation, reported_at: r.event.reported_at };
568
+ },
356
569
  ledger: new TwinLedger(ctx.store.dir), stateDir: ctx.store.dir, device,
357
570
  log: line => { ctx.io.stderr.write(line + '\n'); }, now: Date.now,
358
571
  });
359
572
  await runTwinLoop({
360
573
  tick: s => engine.tick(s), resetBackoff: () => engine.resetBackoff(),
361
574
  sleep: (ms, s) => delay(ms, undefined, { signal: s }).catch(() => undefined),
362
- wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl),
575
+ wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl), kicked: () => engine.pushed(),
363
576
  log: line => { ctx.io.stderr.write(line + '\n'); },
364
577
  }, signal);
365
578
  }
@@ -221,6 +221,11 @@ export type RoundOutcome = {
221
221
  waitMs?: number;
222
222
  land?: LandResult;
223
223
  timings: Record<string, number>;
224
+ } | {
225
+ state: 'deferred';
226
+ reason: string;
227
+ land: LandResult;
228
+ timings: Record<string, number>;
224
229
  };
225
230
  /** The landing script, shipped next to this module (src in the monorepo, dist when published). */
226
231
  export declare function landingScript(): string;
@@ -228,6 +233,18 @@ export declare function runLandingScript(cell: TwinCell, input: Record<string, u
228
233
  /** A temporary repository in the cell with the conversation's branch (and its earlier landing refs) from the hub. */
229
234
  export declare const HUB_CHECKOUT = "set -eu\nd=$(mktemp -d \"${TMPDIR:-/tmp}/shard-land-XXXXXX\")\ngit init -q \"$d\"\ncd \"$d\"\ngit fetch -q --no-tags \"$1\" \"+refs/heads/$2:refs/heads/$2\"\ngit fetch -q --no-tags \"$1\" \"+refs/shard/land/$3/upto:refs/shard/land/upto\" 2>/dev/null || true\ngit symbolic-ref HEAD \"refs/heads/$2\"\nif [ -n \"$4\" ]; then git remote add origin \"$4\"; fi\nprintf '%s\\n' \"$d\"\n";
230
235
  export declare function landingRound(cell: TwinCell, input: RoundInput): Promise<RoundOutcome>;
236
+ export interface BatchItem {
237
+ cell: TwinCell;
238
+ conversation: RoundConversation;
239
+ upto?: string;
240
+ known?: string[];
241
+ }
242
+ /**
243
+ * Lands several finished conversations into one branch with one apply: one twin, the merges in their VMs in parallel,
244
+ * then their new commits stacked here in the given (finish) order and the files written once. A conversation whose
245
+ * commits do not stack cleanly on the earlier ones is 'deferred' (it lands next, against the new state).
246
+ */
247
+ export declare function landingBatch(items: BatchItem[], shared: Omit<RoundInput, 'conversation' | 'upto' | 'known' | 'fromHub' | 'mode'>): Promise<RoundOutcome[]>;
231
248
  export type LandingMode = 'auto' | 'pr' | 'off';
232
249
  export interface TwinConversationState {
233
250
  base_branch?: string;
@@ -516,19 +516,23 @@ export async function landingRound(cell, input) {
516
516
  await cell.exec.run(['rm', '-rf', '--', dir], { timeoutMs: 60_000 }).catch(() => undefined);
517
517
  }
518
518
  }
519
- async function landingRoundIn(cell, input) {
520
- const now = input.now ?? Date.now, t0 = now(), timings = {};
521
- const mark = (name) => { timings[name] = now() - t0; };
522
- const c = input.conversation, branch = `refs/heads/${input.into}`, hub = input.hub ?? HUB_PATH, vmTmp = input.vmTmp ?? '/tmp';
519
+ /** The twin of the checkout where the branch is checked out (or of the branch itself), or why not now. */
520
+ async function takeSnapshot(input) {
521
+ const branch = `refs/heads/${input.into}`;
523
522
  if (input.worktree) {
524
523
  const reason = pauseReason(input.worktree);
525
524
  if (reason)
526
- return { state: 'paused', reason, timings };
525
+ return { state: 'paused', reason };
527
526
  }
528
527
  const snap = input.worktree ? await snapshotWorktree(input.worktree, { device: input.device, stateDir: input.stateDir, signal: input.signal }) : await refTwin(input.repo, branch, input.device);
529
528
  if (input.worktree && snap.base !== await verifyCommit(input.repo, branch))
530
- return { state: 'moved', reason: 'branch_switched', timings };
531
- mark('snapshot');
529
+ return { state: 'moved', reason: 'branch_switched' };
530
+ return snap;
531
+ }
532
+ const landRefs = (name) => ({ tip: `refs/shard/land/${name}/tip`, result: `refs/shard/land/${name}/result` });
533
+ /** The landing script in the conversation's VM against the twin; a ready landing's tip and result are fetched here. */
534
+ async function mergeInVm(cell, input, snap, mark) {
535
+ const c = input.conversation, hub = input.hub ?? HUB_PATH, vmTmp = input.vmTmp ?? '/tmp';
532
536
  const out = `${vmTmp}/shard-land-${randomUUID()}.bundle`;
533
537
  const send = async (known) => {
534
538
  // The VM has the conversation's base and what earlier landings made there; the bundle carries the rest.
@@ -582,9 +586,8 @@ async function landingRoundIn(cell, input) {
582
586
  await runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into }, input.signal).catch(() => undefined);
583
587
  }
584
588
  if (land.state !== 'ready')
585
- return { state: land.state === 'error' ? 'error' : land.state, land, timings };
586
- const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`);
587
- const tipRef = `refs/shard/land/${c.name}/tip`, resultRef = `refs/shard/land/${c.name}/result`;
589
+ return land;
590
+ const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`), refs = landRefs(c.name);
588
591
  try {
589
592
  let bytes;
590
593
  if (land.bundle_b64)
@@ -598,8 +601,8 @@ async function landingRoundIn(cell, input) {
598
601
  throw new Error('the landing bundle changed while it was read');
599
602
  }
600
603
  await writeFile(fetched, bytes, { mode: 0o600 });
601
- await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${tipRef}`, `+refs/shard/land/result:${resultRef}`]);
602
- if (await verifyCommit(input.repo, tipRef) !== land.tip || await verifyCommit(input.repo, resultRef) !== land.result)
604
+ await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${refs.tip}`, `+refs/shard/land/result:${refs.result}`]);
605
+ if (await verifyCommit(input.repo, refs.tip) !== land.tip || await verifyCommit(input.repo, refs.result) !== land.result)
603
606
  throw new Error('the landing bundle does not match the landing');
604
607
  }
605
608
  finally {
@@ -608,22 +611,144 @@ async function landingRoundIn(cell, input) {
608
611
  await cell.files.remove(out).catch(() => undefined);
609
612
  }
610
613
  mark('download');
611
- const outcome = await applyLanding({ worktree: input.worktree, repo: input.repo, branch, base: land.base, twin: land.twin, tip: land.tip, result: land.result }, { backupRoot: input.backupRoot, quietMs: input.quietMs, now, label: { conversation_id: c.id, name: c.name, title: c.title } });
612
- mark('apply');
613
- await git(input.repo, ['update-ref', '-d', resultRef], { ok: [0, 1] });
614
- await git(input.repo, ['update-ref', '-d', tipRef], { ok: [0, 1] });
615
- if (outcome.state !== 'applied')
616
- return { ...outcome, reason: 'reason' in outcome ? outcome.reason : 'busy', land, timings };
617
- // The files are on disk. Recording the landing in the VM and copying it to the project hub (shared storage, seconds)
618
- // runs after, without holding up the next landing.
614
+ return land;
615
+ }
616
+ async function dropLandRefs(repo, name) {
617
+ const refs = landRefs(name);
618
+ await git(repo, ['update-ref', '-d', refs.result], { ok: [0, 1] });
619
+ await git(repo, ['update-ref', '-d', refs.tip], { ok: [0, 1] });
620
+ }
621
+ /**
622
+ * The files are on disk. Recording the landing in the VM and copying it to the project hub (shared storage, seconds)
623
+ * runs after, without holding up the next landing.
624
+ */
625
+ function confirmInVm(cell, input, snap, land, timings, since, now) {
626
+ const c = input.conversation, hub = input.hub ?? HUB_PATH;
619
627
  const confirmed = runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into,
620
628
  twin: land.twin, hub_twin_ref: hubTwinRef(input.device, snap.id), tip: land.tip, result: land.result }, input.signal).then(r => {
621
629
  if (r.state === 'error')
622
630
  throw new Error(`landing confirmation failed: ${r.message ?? r.reason}`);
623
- timings.confirm = now() - t0;
631
+ timings.confirm = now() - since;
624
632
  });
625
633
  confirmed.catch(() => undefined);
626
- return { state: 'landed', land, applied: outcome, timings, confirmed };
634
+ return confirmed;
635
+ }
636
+ async function landingRoundIn(cell, input) {
637
+ const now = input.now ?? Date.now, t0 = now(), timings = {};
638
+ const mark = (name) => { timings[name] = now() - t0; };
639
+ const c = input.conversation, branch = `refs/heads/${input.into}`;
640
+ const snap = await takeSnapshot(input);
641
+ if ('state' in snap)
642
+ return { ...snap, timings };
643
+ mark('snapshot');
644
+ const land = await mergeInVm(cell, input, snap, mark);
645
+ if (land.state !== 'ready')
646
+ return { state: land.state === 'error' ? 'error' : land.state, land, timings };
647
+ const outcome = await applyLanding({ worktree: input.worktree, repo: input.repo, branch, base: land.base, twin: land.twin, tip: land.tip, result: land.result }, { backupRoot: input.backupRoot, quietMs: input.quietMs, now, label: { conversation_id: c.id, name: c.name, title: c.title } });
648
+ mark('apply');
649
+ await dropLandRefs(input.repo, c.name);
650
+ if (outcome.state !== 'applied')
651
+ return { ...outcome, reason: 'reason' in outcome ? outcome.reason : 'busy', land, timings };
652
+ return { state: 'landed', land, applied: outcome, timings, confirmed: confirmInVm(cell, input, snap, land, timings, t0, now) };
653
+ }
654
+ async function mergeTree(repo, base, ours, theirs, strategy) {
655
+ const r = await git(repo, ['merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', `--merge-base=${base}`, ...(strategy ? ['-X', strategy] : []), ours, theirs], { ok: [0, 1] });
656
+ const parts = r.stdout.toString('utf8').split('\0');
657
+ return { tree: parts[0].trim(), conflicts: r.code === 1 ? [...new Set(parts.slice(1).filter(Boolean))].sort() : [] };
658
+ }
659
+ /** cur with each first-parent commit of base..tip replayed on it (author and message kept), or null on a conflict. */
660
+ async function replayOnto(repo, cur, base, tip) {
661
+ const commits = (await gitText(repo, ['rev-list', '--reverse', '--first-parent', tip, `^${base}`])).split('\n').filter(Boolean);
662
+ for (const x of commits) {
663
+ const parent = await verifyCommit(repo, `${x}^1`);
664
+ if (!parent)
665
+ return null;
666
+ const { tree, conflicts } = await mergeTree(repo, parent, cur, x);
667
+ if (conflicts.length)
668
+ return null;
669
+ if (tree === await gitText(repo, ['rev-parse', `${cur}^{tree}`]))
670
+ continue;
671
+ const [name, email, date] = (await gitText(repo, ['log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', x])).split('\0');
672
+ const raw = (await git(repo, ['cat-file', 'commit', x])).stdout, message = raw.subarray(raw.indexOf('\n\n') + 2);
673
+ cur = await gitText(repo, ['commit-tree', tree, '-p', cur, '-F', '-'], { input: message, env: { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date } });
674
+ }
675
+ return cur;
676
+ }
677
+ /** The user's working state with the branch's new commits merged in; where both changed the same lines, the user's text. */
678
+ async function resultCommit(repo, base, twin, tip) {
679
+ let { tree, conflicts } = await mergeTree(repo, base, twin, tip);
680
+ const shadowed = conflicts;
681
+ if (conflicts.length) {
682
+ ({ tree, conflicts } = await mergeTree(repo, base, twin, tip, 'ours'));
683
+ if (conflicts.length) {
684
+ const dir = join(tmpdir(), `shard-index-${randomUUID()}`), env = { GIT_INDEX_FILE: dir };
685
+ try {
686
+ await git(repo, ['read-tree', tree], { env });
687
+ await git(repo, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...conflicts], { env });
688
+ const listing = (await git(repo, ['ls-tree', '-r', '-z', '--full-tree', twin, '--', ...conflicts])).stdout.toString('utf8').split('\0').filter(Boolean)
689
+ .map(l => l.replace(/ (blob|commit) /, ' '));
690
+ if (listing.length)
691
+ await git(repo, ['update-index', '-z', '--index-info'], { env, input: listing.join('\0') + '\0' });
692
+ tree = await gitText(repo, ['write-tree'], { env });
693
+ }
694
+ finally {
695
+ rmSync(dir, { force: true });
696
+ }
697
+ }
698
+ }
699
+ const commit = await gitText(repo, ['commit-tree', tree, '-p', tip, '-F', '-'], { input: 'Shardflux landing result\n', env: { GIT_AUTHOR_NAME: 'Shardflux', GIT_AUTHOR_EMAIL: 'landing@shardflux.dev', GIT_COMMITTER_NAME: 'Shardflux', GIT_COMMITTER_EMAIL: 'landing@shardflux.dev' } });
700
+ return { commit, shadowed };
701
+ }
702
+ /**
703
+ * Lands several finished conversations into one branch with one apply: one twin, the merges in their VMs in parallel,
704
+ * then their new commits stacked here in the given (finish) order and the files written once. A conversation whose
705
+ * commits do not stack cleanly on the earlier ones is 'deferred' (it lands next, against the new state).
706
+ */
707
+ export async function landingBatch(items, shared) {
708
+ const now = shared.now ?? Date.now, t0 = now(), timings = {};
709
+ const mark = (name) => { timings[name] = now() - t0; };
710
+ const snap = await takeSnapshot(shared);
711
+ if ('state' in snap)
712
+ return items.map(() => ({ ...snap, timings }));
713
+ mark('snapshot');
714
+ const inputs = items.map(it => ({ ...shared, mode: 'auto', conversation: it.conversation, upto: it.upto, known: it.known }));
715
+ const lands = await Promise.all(items.map((it, i) => mergeInVm(it.cell, inputs[i], snap, () => undefined)
716
+ .catch((error) => ({ state: 'error', reason: 'exception', message: error instanceof Error ? error.message : String(error) }))));
717
+ mark('merge');
718
+ const outcomes = lands.map(land => land.state === 'ready' ? undefined : { state: land.state === 'error' ? 'error' : land.state, land, timings });
719
+ const branch = `refs/heads/${shared.into}`, base = snap.base, included = [];
720
+ let cur = base;
721
+ for (const [i, land] of lands.entries()) {
722
+ if (land.state !== 'ready')
723
+ continue;
724
+ const next = cur === base ? land.tip : await replayOnto(shared.repo, cur, base, land.tip);
725
+ if (next) {
726
+ cur = next;
727
+ included.push(i);
728
+ }
729
+ else
730
+ outcomes[i] = { state: 'deferred', reason: 'stacked', land, timings };
731
+ }
732
+ try {
733
+ if (!included.length)
734
+ return outcomes.map(o => o);
735
+ const result = included.length === 1 ? { commit: lands[included[0]].result, shadowed: lands[included[0]].shadowed ?? [] } : await resultCommit(shared.repo, base, snap.commit, cur);
736
+ mark('stack');
737
+ const first = items[included[0]].conversation;
738
+ const applied = await applyLanding({ worktree: shared.worktree, repo: shared.repo, branch, base, twin: snap.commit, tip: cur, result: result.commit }, { backupRoot: shared.backupRoot, quietMs: shared.quietMs, now, label: { conversation_id: first.id, name: included.map(i => items[i].conversation.name).join('+'), title: first.title } });
739
+ mark('apply');
740
+ for (const i of included) {
741
+ const land = { ...lands[i], shadowed: result.shadowed };
742
+ outcomes[i] = applied.state === 'applied'
743
+ ? { state: 'landed', land, applied, timings, confirmed: confirmInVm(items[i].cell, inputs[i], snap, land, timings, t0, now) }
744
+ : { ...applied, reason: 'reason' in applied ? applied.reason : 'busy', land, timings };
745
+ }
746
+ return outcomes.map(o => o);
747
+ }
748
+ finally {
749
+ for (const it of items)
750
+ await dropLandRefs(shared.repo, it.conversation.name);
751
+ }
627
752
  }
628
753
  export class TwinLedger {
629
754
  store;