@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.
- package/CHANGELOG.md +62 -2
- package/README.md +43 -5
- package/dist/agent-commands.d.ts +9 -1
- package/dist/agent-commands.js +65 -16
- package/dist/agent-models.d.ts +61 -0
- package/dist/agent-models.js +79 -0
- package/dist/agent-state.d.ts +10 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +6 -1
- package/dist/agent-target-commands.js +117 -16
- package/dist/agent-targets.d.ts +2 -0
- package/dist/agent-targets.js +10 -4
- package/dist/args.d.ts +5 -1
- package/dist/args.js +49 -9
- package/dist/cli-proxy-forward.d.ts +45 -0
- package/dist/cli-proxy-forward.js +432 -0
- package/dist/cli-proxy-hub.d.ts +273 -0
- package/dist/cli-proxy-hub.js +749 -0
- package/dist/cli-proxy.d.ts +325 -0
- package/dist/cli-proxy.js +1016 -0
- package/dist/codex-cloud.d.ts +2 -1
- package/dist/codex-cloud.js +16 -6
- package/dist/command-center-actions.js +16 -1
- package/dist/conversation-local.js +2 -2
- package/dist/conversation-stash.js +4 -3
- package/dist/conversation-terminal.d.ts +5 -0
- package/dist/conversation-terminal.js +45 -6
- package/dist/conversations.d.ts +18 -2
- package/dist/conversations.js +12 -5
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +73 -0
- package/dist/device-helper.d.ts +3 -1
- package/dist/device-helper.js +5 -2
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +1 -1
- package/dist/handlers.js +3 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/linux-helper.d.ts +12 -0
- package/dist/linux-helper.js +88 -0
- package/dist/linux-terminal.d.ts +11 -0
- package/dist/linux-terminal.js +42 -0
- package/dist/main.js +29 -6
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-mode.d.ts +121 -0
- package/dist/t3-mode.js +654 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-thread-script.d.ts +18 -0
- package/dist/t3-thread-script.js +245 -0
- package/dist/terminal-onboarding.d.ts +11 -0
- package/dist/terminal-onboarding.js +75 -0
- package/dist/thread-on-computer.d.ts +2 -2
- package/dist/thread-on-computer.js +13 -7
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/worktree-twin-engine.d.ts +31 -1
- package/dist/worktree-twin-engine.js +253 -40
- package/dist/worktree-twin.d.ts +17 -0
- package/dist/worktree-twin.js +147 -22
- 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
|
-
/**
|
|
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
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
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
|
|
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, {
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/worktree-twin.d.ts
CHANGED
|
@@ -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;
|
package/dist/worktree-twin.js
CHANGED
|
@@ -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
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
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
|
|
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'
|
|
531
|
-
|
|
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
|
|
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:${
|
|
602
|
-
if (await verifyCommit(input.repo,
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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() -
|
|
631
|
+
timings.confirm = now() - since;
|
|
624
632
|
});
|
|
625
633
|
confirmed.catch(() => undefined);
|
|
626
|
-
return
|
|
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;
|