@shardflux/cli 0.15.2 → 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 +144 -14
- package/README.md +92 -7
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +15 -1
- package/dist/agent-commands.js +84 -23
- 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 +13 -3
- package/dist/agent-target-commands.js +129 -23
- package/dist/agent-targets.d.ts +5 -0
- package/dist/agent-targets.js +14 -5
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +5 -1
- package/dist/args.js +74 -11
- 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 +3 -0
- package/dist/codex-cloud.js +17 -5
- package/dist/command-center-actions.js +25 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- 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 +54 -6
- package/dist/conversations.d.ts +31 -3
- package/dist/conversations.js +53 -10
- 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 +14 -4
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -2
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +5 -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 +38 -8
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +53 -5
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- 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 +35 -0
- package/dist/thread-on-computer.js +206 -0
- package/dist/update.d.ts +1 -1
- package/dist/update.js +12 -1
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +125 -0
- package/dist/worktree-twin-engine.js +615 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +313 -0
- package/dist/worktree-twin.js +843 -0
- package/package.json +3 -3
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device helper's live twin loop (0.16.0+): watches the API (conversations and agent sessions, never a VM) for
|
|
3
|
+
* conversations whose turn ended with committed work and lands each into the local branch it started from, with no
|
|
4
|
+
* input. One landing at a time, in the order the turns finished; anything unsafe pauses and retries. A conversation
|
|
5
|
+
* that ended while this computer was away (its VM reclaimed) lands from the project hub. After being offline or asleep
|
|
6
|
+
* the loop retries at once (runTwinLoop). See worktree-twin.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { connect } from 'node:net';
|
|
9
|
+
import { networkInterfaces } from 'node:os';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
|
+
import { out } from "./commands.js";
|
|
13
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
14
|
+
import { conversationName, conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
|
|
15
|
+
import { UsageError } from "./errors.js";
|
|
16
|
+
import { projectIdentity } from "./project-identity.js";
|
|
17
|
+
import { TwinLedger, backupRoot, inspectWorktree, landingBatch, landingRound, listWorktrees, pauseReason, restoreBackup } from "./worktree-twin.js";
|
|
18
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
/** Its VM is gone: the conversation ended (archived after the retention window, or done). */
|
|
21
|
+
export const ended = (c) => !!c.ended_at || c.state === 'archived';
|
|
22
|
+
const revision = (c) => JSON.stringify([c.changes, c.summary]);
|
|
23
|
+
/** A turn ended with committed work only; the agent is not asking anything. */
|
|
24
|
+
export function finished(c, agents) {
|
|
25
|
+
if (ended(c))
|
|
26
|
+
return 'state';
|
|
27
|
+
const agent = agents.find(a => a.workspace_id === c.workspace_id && c.harness_session_id && a.id === `${c.workspace_id}:${c.harness_session_id}`);
|
|
28
|
+
if (agent)
|
|
29
|
+
return agent.state === 'working' ? null : agent;
|
|
30
|
+
return c.state === 'unresolved' || c.state === 'done' ? 'state' : null;
|
|
31
|
+
}
|
|
32
|
+
export function eligible(c, ledger, now) {
|
|
33
|
+
if (c.route === 'codex-cloud' || c.join_mode === 'reviewer' || c.state === 'needs_you')
|
|
34
|
+
return false;
|
|
35
|
+
if (!c.changes || c.changes.commits <= 0 || c.changes.uncommitted || c.landed?.how === 'discarded')
|
|
36
|
+
return false;
|
|
37
|
+
const st = ledger.conversation(c.id);
|
|
38
|
+
// An ended conversation is landed here unless someone landed it some other way before it ended.
|
|
39
|
+
if (ended(c) && c.landed && !st.landed_revision)
|
|
40
|
+
return false;
|
|
41
|
+
if ((st.retry_at ?? 0) > now)
|
|
42
|
+
return false;
|
|
43
|
+
if (st.landed_revision === revision(c) && (ended(c) || c.landed?.how === 'merged' || c.landed?.how === 'pr'))
|
|
44
|
+
return false;
|
|
45
|
+
return st.conflict_revision !== revision(c);
|
|
46
|
+
}
|
|
47
|
+
export function conflictMessage(into, files) {
|
|
48
|
+
const list = files.length > 8 ? `${files.slice(0, 8).join(', ')} and ${files.length - 8} more` : files.join(', ');
|
|
49
|
+
return `Shardflux could not land your commits into ${into} on the user's computer: ${list} also changed in the user's newer commits on ${into}. `
|
|
50
|
+
+ `Merge their branch into yours and commit: git merge refs/shard/twin-in^1 (refs/shard/twin-in is the user's working state as of now; its first parent is the tip of their ${into}). `
|
|
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
|
+
+ `Your work lands on the user's computer automatically when you finish. Ask the user only if the result cannot work either way.`;
|
|
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;
|
|
59
|
+
export class TwinEngine {
|
|
60
|
+
#deps;
|
|
61
|
+
#reported = new Map();
|
|
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;
|
|
69
|
+
constructor(deps) { this.#deps = deps; }
|
|
70
|
+
#report(key, line) { if (this.#reported.get(key) !== line) {
|
|
71
|
+
this.#reported.set(key, line);
|
|
72
|
+
this.#deps.log(line);
|
|
73
|
+
} }
|
|
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
|
+
*/
|
|
87
|
+
async tick(signal) {
|
|
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();
|
|
112
|
+
for (const project of new Set(records.map(r => r.project_id))) {
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
const mode = d.ledger.mode(project);
|
|
115
|
+
if (mode === 'off')
|
|
116
|
+
continue;
|
|
117
|
+
const mine = new Map(records.filter(r => r.project_id === project).map(r => [r.conversation.id, r]));
|
|
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()));
|
|
126
|
+
if (!candidates.length)
|
|
127
|
+
continue;
|
|
128
|
+
const agents = candidates.some(c => !ended(c)) ? await d.agents(project) : [];
|
|
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) });
|
|
136
|
+
}
|
|
137
|
+
wait = Math.min(wait, await this.#land(ready, signal));
|
|
138
|
+
}
|
|
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
|
+
})();
|
|
213
|
+
}
|
|
214
|
+
/** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
|
|
215
|
+
resetBackoff() {
|
|
216
|
+
this.#deps.ledger.update(doc => { for (const st of Object.values(doc.conversations ?? {})) {
|
|
217
|
+
st.failures = 0;
|
|
218
|
+
st.retry_at = 0;
|
|
219
|
+
} });
|
|
220
|
+
this.#reported.clear();
|
|
221
|
+
this.#nextFull = -Infinity;
|
|
222
|
+
}
|
|
223
|
+
/** Everything a landing needs here, or undefined (nothing to do now; reported). */
|
|
224
|
+
async #prepare(c, record, agent) {
|
|
225
|
+
const d = this.#deps, st = d.ledger.conversation(c.id), rev = revision(c);
|
|
226
|
+
const into = c.base_branch ?? record.base_branch ?? st.base_branch;
|
|
227
|
+
const name = c.name ?? conversationName(c);
|
|
228
|
+
const label = `${c.title || name} (${c.id})`;
|
|
229
|
+
if (!into || into.startsWith('shard/')) {
|
|
230
|
+
this.#report(c.id, `Twin: ${label} has no local base branch to land into.`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!existsSync(record.folder)) {
|
|
234
|
+
this.#report(c.id, `Twin: ${record.folder} is gone; ${label} stays in the cloud.`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Landed at this revision already; only the API record is missing (a later report reset it on an older guest).
|
|
238
|
+
if (st.landed_revision === rev) {
|
|
239
|
+
if (ended(c))
|
|
240
|
+
return;
|
|
241
|
+
if ((this.#posted.get(c.id) ?? 0) + 30_000 < d.now()) {
|
|
242
|
+
this.#posted.set(c.id, d.now());
|
|
243
|
+
await d.land(c, st.pr_url ? 'pr' : 'merge', st.pr_url ?? into);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
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;
|
|
258
|
+
let vm, fromHub = false;
|
|
259
|
+
try {
|
|
260
|
+
vm = ended(c) ? null : await d.cell(c);
|
|
261
|
+
// The VM is gone (reclaimed while this computer was away): its branch is in the project hub.
|
|
262
|
+
if (!vm) {
|
|
263
|
+
vm = await d.hubCell(c.project_id);
|
|
264
|
+
fromHub = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (signal?.aborted)
|
|
269
|
+
throw error;
|
|
270
|
+
return this.#fail(c, p.label, error instanceof Error ? error.message : String(error));
|
|
271
|
+
}
|
|
272
|
+
let outcome;
|
|
273
|
+
try {
|
|
274
|
+
let worktree = p.worktree;
|
|
275
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
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',
|
|
279
|
+
...(fromHub ? { fromHub: { ...(c.repo_remote ? { repoRemote: c.repo_remote } : {}) } } : {}) });
|
|
280
|
+
if (outcome.state !== 'moved')
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
vm.close();
|
|
286
|
+
if (signal?.aborted)
|
|
287
|
+
throw 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;
|
|
311
|
+
}
|
|
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)
|
|
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);
|
|
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();
|
|
356
|
+
const post = async (action, ref) => {
|
|
357
|
+
// An ended conversation cannot record a landing any more (the API refuses); the ledger keeps it.
|
|
358
|
+
if (ended(c))
|
|
359
|
+
return;
|
|
360
|
+
this.#posted.set(c.id, d.now());
|
|
361
|
+
await d.land(c, action, ref);
|
|
362
|
+
};
|
|
363
|
+
switch (o.state) {
|
|
364
|
+
case 'landed': {
|
|
365
|
+
// The VM records the landing and copies it to the project hub on its own time; the connection closes after.
|
|
366
|
+
void o.confirmed.catch((error) => { this.#report(c.id, `Twin: ${label} landed; recording it in its VM failed: ${error instanceof Error ? error.message : String(error)}.`); })
|
|
367
|
+
.finally(() => vm.close());
|
|
368
|
+
const latency = agent ? o.applied.at - Date.parse(agent.since) : undefined;
|
|
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,
|
|
370
|
+
failures: 0, retry_at: 0, last_error: undefined, ...(latency !== undefined ? { latency_ms: latency } : {}) });
|
|
371
|
+
await post('merge', into);
|
|
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'}`
|
|
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(', ')}` : ''}`
|
|
374
|
+
+ `${latency !== undefined ? `; ${(latency / 1000).toFixed(1)} s after the turn ended, round started at ${((started - Date.parse(agent.since)) / 1000).toFixed(1)} s` : ''}`
|
|
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(', ')}).`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
case 'noop':
|
|
379
|
+
d.ledger.patch(c.id, { upto: o.land.upto, landed_revision: rev, failures: 0, retry_at: 0 });
|
|
380
|
+
if (c.landed?.how !== 'merged')
|
|
381
|
+
await post('merge', into);
|
|
382
|
+
return;
|
|
383
|
+
case 'pr':
|
|
384
|
+
d.ledger.patch(c.id, { tip: o.land.tip, landed_revision: rev, pr_url: o.land.url, failures: 0, retry_at: 0 });
|
|
385
|
+
await post('pr', o.land.url);
|
|
386
|
+
d.log(`Twin: opened ${o.land.url} for ${label}.`);
|
|
387
|
+
return;
|
|
388
|
+
case 'conflict':
|
|
389
|
+
if (st.conflict_tip !== o.land.conversation_tip) {
|
|
390
|
+
// Its agent resolves it; an ended conversation is restored into a fresh VM first.
|
|
391
|
+
if (ended(c))
|
|
392
|
+
await d.resume(c);
|
|
393
|
+
await d.message(c, conflictMessage(into, o.land.conflicts ?? []));
|
|
394
|
+
}
|
|
395
|
+
d.ledger.patch(c.id, { conflict_tip: o.land.conversation_tip, conflict_revision: rev });
|
|
396
|
+
d.log(`Twin: ${label} conflicts with your commits on ${into} (${(o.land.conflicts ?? []).join(', ')}); its agent is resolving it.`);
|
|
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;
|
|
402
|
+
case 'busy':
|
|
403
|
+
d.ledger.patch(c.id, { retry_at: d.now() + (o.waitMs ?? 1000) });
|
|
404
|
+
return o.waitMs ?? 1000;
|
|
405
|
+
case 'paused': return 1000;
|
|
406
|
+
case 'moved':
|
|
407
|
+
d.ledger.patch(c.id, { retry_at: d.now() + 2000 });
|
|
408
|
+
return 2000;
|
|
409
|
+
case 'error': return this.#fail(c, label, `${o.land.reason}: ${o.land.message ?? ''}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
#fail(c, label, message) {
|
|
413
|
+
const d = this.#deps, failures = (d.ledger.conversation(c.id).failures ?? 0) + 1, wait = Math.min(300_000, 5000 * 2 ** Math.min(failures - 1, 6));
|
|
414
|
+
d.ledger.patch(c.id, { failures, retry_at: d.now() + wait, last_error: message });
|
|
415
|
+
this.#report(c.id, `Twin: ${label} could not land yet: ${message} (retrying in ${Math.round(wait / 1000)} s).`);
|
|
416
|
+
return wait;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const PROBE_MS = 5000, JUMP_MS = 5000;
|
|
420
|
+
/**
|
|
421
|
+
* Runs the twin's passes. A failing pass (offline) backs off up to 5 minutes, but the wait ends at once when the
|
|
422
|
+
* computer wakes from sleep (the wall clock jumps past the monotonic one), when its network changes, or when the API
|
|
423
|
+
* host answers again (probed every 5 s while failing); every conversation's retry backoff is then cleared and a pass
|
|
424
|
+
* runs immediately. The first pass that succeeds after failures clears them too and runs again at once.
|
|
425
|
+
*/
|
|
426
|
+
export async function runTwinLoop(deps, signal) {
|
|
427
|
+
let failures = 0, net = deps.network();
|
|
428
|
+
const reported = new Set();
|
|
429
|
+
while (!signal.aborted) {
|
|
430
|
+
let wait;
|
|
431
|
+
try {
|
|
432
|
+
wait = await deps.tick(signal);
|
|
433
|
+
if (failures) {
|
|
434
|
+
failures = 0;
|
|
435
|
+
deps.resetBackoff();
|
|
436
|
+
wait = 0;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (signal.aborted)
|
|
441
|
+
break;
|
|
442
|
+
wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
|
|
443
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
444
|
+
if (!conversationsUnavailable(error) && !reported.has(message)) {
|
|
445
|
+
reported.add(message);
|
|
446
|
+
deps.log(`Twin: ${message} (retrying in ${wait / 1000} s).`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
let left = wait, probed = deps.mono();
|
|
450
|
+
while (left > 0 && !signal.aborted) {
|
|
451
|
+
const slice = Math.min(1000, left), wall = deps.wall(), mono = deps.mono();
|
|
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;
|
|
458
|
+
left -= slice;
|
|
459
|
+
const woke = deps.wall() - wall - (deps.mono() - mono) > JUMP_MS;
|
|
460
|
+
const now = deps.network(), changed = now !== net;
|
|
461
|
+
net = now;
|
|
462
|
+
let back = false;
|
|
463
|
+
if (failures && deps.mono() - probed >= PROBE_MS) {
|
|
464
|
+
probed = deps.mono();
|
|
465
|
+
back = await deps.reachable().catch(() => false);
|
|
466
|
+
}
|
|
467
|
+
if (woke || changed || back) {
|
|
468
|
+
deps.resetBackoff();
|
|
469
|
+
left = 0;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function networkSignature() {
|
|
475
|
+
const all = [];
|
|
476
|
+
for (const [name, list] of Object.entries(networkInterfaces()))
|
|
477
|
+
for (const a of list ?? [])
|
|
478
|
+
if (!a.internal)
|
|
479
|
+
all.push(`${name} ${a.address}`);
|
|
480
|
+
return all.sort().join(',');
|
|
481
|
+
}
|
|
482
|
+
function reachable(url) {
|
|
483
|
+
const u = new URL(url), port = Number(u.port || (u.protocol === 'http:' ? 80 : 443));
|
|
484
|
+
return new Promise(resolve => {
|
|
485
|
+
const socket = connect({ host: u.hostname, port, timeout: 3000 });
|
|
486
|
+
const done = (ok) => { socket.destroy(); resolve(ok); };
|
|
487
|
+
socket.once('connect', () => done(true));
|
|
488
|
+
socket.once('timeout', () => done(false));
|
|
489
|
+
socket.once('error', () => done(false));
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const gone = (error) => error.status === 404 || error.details?.reason === 'workspace_deleted';
|
|
493
|
+
/** The device helper's twin. Nothing here asks the user anything. */
|
|
494
|
+
export async function runWorktreeTwin(ctx, device, signal) {
|
|
495
|
+
const client = () => conversationClient(ctx);
|
|
496
|
+
const open = (ws) => {
|
|
497
|
+
const cell = ws.cell({ agentLabel: 'shard-twin' });
|
|
498
|
+
return { cell, close: () => cell.close() };
|
|
499
|
+
};
|
|
500
|
+
const engine = new TwinEngine({
|
|
501
|
+
records: () => Object.values(localConversations(ctx)),
|
|
502
|
+
conversations: async (projectId) => await listConversations(ctx, projectId),
|
|
503
|
+
agents: async (projectId) => {
|
|
504
|
+
// A signed-in session without a project key has no agent list here; the conversation state then decides.
|
|
505
|
+
const api = client(), all = [];
|
|
506
|
+
if (!('agents' in api))
|
|
507
|
+
return all;
|
|
508
|
+
let cursor;
|
|
509
|
+
do {
|
|
510
|
+
const page = await api.agents.list({ projectId, limit: 200, ...(cursor ? { cursor } : {}) });
|
|
511
|
+
all.push(...page.data);
|
|
512
|
+
cursor = page.nextCursor ?? undefined;
|
|
513
|
+
} while (cursor);
|
|
514
|
+
return all;
|
|
515
|
+
},
|
|
516
|
+
cell: async (c) => {
|
|
517
|
+
let ws;
|
|
518
|
+
try {
|
|
519
|
+
ws = await client().workspaces.get(c.workspace_id);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
if (gone(error))
|
|
523
|
+
return null;
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
if (ws.data.deleted_at)
|
|
527
|
+
return null;
|
|
528
|
+
if (!ws.ready)
|
|
529
|
+
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal });
|
|
530
|
+
return open(ws);
|
|
531
|
+
},
|
|
532
|
+
hubCell: async (projectId) => {
|
|
533
|
+
// An awake VM of the project reads the hub without waking anything; else the project's small space reader.
|
|
534
|
+
const all = (await listConversations(ctx, projectId)).filter(c => !ended(c))
|
|
535
|
+
.sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
|
|
536
|
+
for (const c of all) {
|
|
537
|
+
try {
|
|
538
|
+
const ws = await client().workspaces.get(c.workspace_id);
|
|
539
|
+
if (!ws.data.deleted_at && ws.ready)
|
|
540
|
+
return open(ws);
|
|
541
|
+
}
|
|
542
|
+
catch { /* another may be awake */ }
|
|
543
|
+
}
|
|
544
|
+
const reader = await client().conversations.spaceReader(projectId, { signal });
|
|
545
|
+
const ws = await client().workspaces.get(reader.workspace.id);
|
|
546
|
+
if (!ws.ready)
|
|
547
|
+
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal });
|
|
548
|
+
return open(ws);
|
|
549
|
+
},
|
|
550
|
+
resume: async (c) => { await client().conversations.resume(c.id, { signal }); },
|
|
551
|
+
land: async (c, action, into) => { await client().conversations.land(c.id, action, { into, signal }); },
|
|
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
|
+
},
|
|
569
|
+
ledger: new TwinLedger(ctx.store.dir), stateDir: ctx.store.dir, device,
|
|
570
|
+
log: line => { ctx.io.stderr.write(line + '\n'); }, now: Date.now,
|
|
571
|
+
});
|
|
572
|
+
await runTwinLoop({
|
|
573
|
+
tick: s => engine.tick(s), resetBackoff: () => engine.resetBackoff(),
|
|
574
|
+
sleep: (ms, s) => delay(ms, undefined, { signal: s }).catch(() => undefined),
|
|
575
|
+
wall: Date.now, mono: () => performance.now(), network: networkSignature, reachable: () => reachable(ctx.apiUrl), kicked: () => engine.pushed(),
|
|
576
|
+
log: line => { ctx.io.stderr.write(line + '\n'); },
|
|
577
|
+
}, signal);
|
|
578
|
+
}
|
|
579
|
+
const MODES = ['auto', 'pr', 'off'];
|
|
580
|
+
function projectOf(ctx, path) {
|
|
581
|
+
const folder = projectIdentity(path ?? process.cwd()).folder;
|
|
582
|
+
const local = Object.values(localConversations(ctx)).find(r => r.folder === folder);
|
|
583
|
+
const project = local?.project_id ?? ctx.store.profile(ctx.apiUrl).project_id;
|
|
584
|
+
if (!project)
|
|
585
|
+
throw new UsageError('No Shardflux project for this checkout: start a conversation here first, or run shard setup.');
|
|
586
|
+
return project;
|
|
587
|
+
}
|
|
588
|
+
export const TWIN_HANDLERS = {
|
|
589
|
+
/** shard conversations landing [auto|pr|off|restore <backup>|backups] */
|
|
590
|
+
async 'conversations landing'(ctx, values, [action, arg]) {
|
|
591
|
+
const ledger = new TwinLedger(ctx.store.dir), project = projectOf(ctx, typeof values.path === 'string' ? values.path : undefined), root = backupRoot(ctx.store.dir);
|
|
592
|
+
if (action === 'restore') {
|
|
593
|
+
if (!arg || !/^[\w.:-]+$/.test(arg))
|
|
594
|
+
throw new UsageError('Name a backup: shard conversations landing backups');
|
|
595
|
+
const r = await restoreBackup(join(root, arg), { backupRoot: root });
|
|
596
|
+
out(ctx, `Restored ${r.restored.length} file${r.restored.length === 1 ? '' : 's'}. The replaced versions are in backup ${r.backup.split('/').at(-1)}.\n`, r);
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
if (action === 'backups') {
|
|
600
|
+
const list = (existsSync(root) ? readdirSync(root) : []).filter(n => existsSync(join(root, n, 'manifest.json'))).sort().reverse()
|
|
601
|
+
.map(id => { const m = JSON.parse(readFileSync(join(root, id, 'manifest.json'), 'utf8')); return { id, created_at: m.created_at, worktree: m.worktree, title: m.title, files: m.files.map(f => f.path) }; });
|
|
602
|
+
out(ctx, list.map(b => `${b.id}\t${b.title}\t${b.files.length} files\t${b.worktree}`).join('\n') + (list.length ? '\n' : 'No landing backups.\n'), { backups: list });
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
if (action !== undefined) {
|
|
606
|
+
if (!MODES.includes(action))
|
|
607
|
+
throw new UsageError('Choose auto, pr or off (or restore <backup>, backups).');
|
|
608
|
+
ledger.setMode(project, action);
|
|
609
|
+
}
|
|
610
|
+
const mode = ledger.mode(project);
|
|
611
|
+
const text = mode === 'auto' ? 'Finished conversations land in your local branch automatically.' : mode === 'pr' ? 'Finished conversations open a pull request instead of landing locally.' : 'Automatic landing is off for this project.';
|
|
612
|
+
out(ctx, `${text}\n`, { project_id: project, landing: mode });
|
|
613
|
+
return 0;
|
|
614
|
+
},
|
|
615
|
+
};
|