@shardflux/cli 0.16.0 → 0.18.0-next.4
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 +67 -2
- package/README.md +47 -8
- 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 +113 -16
- package/dist/agent-targets.d.ts +17 -5
- package/dist/agent-targets.js +27 -11
- 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/desktop-linux.d.ts +40 -0
- package/dist/desktop-linux.js +186 -0
- package/dist/device-approvals.d.ts +12 -0
- package/dist/device-approvals.js +75 -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/git-remote-bin.js +0 -0
- 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 +30 -6
- package/dist/menubar.js +44 -4
- package/dist/onboarding.d.ts +2 -0
- package/dist/onboarding.js +59 -9
- package/dist/t3-asar.d.ts +10 -0
- package/dist/t3-asar.js +91 -0
- package/dist/t3-launch.d.ts +37 -0
- package/dist/t3-launch.js +133 -0
- package/dist/t3-linux.d.ts +68 -0
- package/dist/t3-linux.js +343 -0
- package/dist/t3-mode.d.ts +129 -0
- package/dist/t3-mode.js +676 -0
- package/dist/t3-phone.d.ts +17 -0
- package/dist/t3-phone.js +15 -0
- package/dist/t3-provider.d.ts +5 -3
- package/dist/t3-provider.js +12 -49
- 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 +28 -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 +7 -19
|
@@ -56,19 +56,46 @@ export interface EngineDeps {
|
|
|
56
56
|
hub?: string;
|
|
57
57
|
vmTmp?: string;
|
|
58
58
|
quietMs?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Long-polls the conversation's next finished turn after `after` (RFC 3339): the turn's conversation and report time,
|
|
61
|
+
* null when the wait timed out. Throws TurnWaitUnsupported when the API has no turn waits.
|
|
62
|
+
*/
|
|
63
|
+
waitTurn?(c: TwinConversation, after: string, signal: AbortSignal): Promise<{
|
|
64
|
+
conversation: TwinConversation;
|
|
65
|
+
reported_at: string;
|
|
66
|
+
} | null | 'ended'>;
|
|
59
67
|
}
|
|
60
68
|
/** A turn ended with committed work only; the agent is not asking anything. */
|
|
61
69
|
export declare function finished(c: TwinConversation, agents: TwinAgent[]): TwinAgent | 'state' | null;
|
|
62
70
|
export declare function eligible(c: TwinConversation, ledger: TwinLedger, now: number): boolean;
|
|
63
71
|
export declare function conflictMessage(into: string, files: string[]): string;
|
|
72
|
+
type Ready = {
|
|
73
|
+
c: TwinConversation;
|
|
74
|
+
record: LocalConversation;
|
|
75
|
+
mode: Exclude<LandingMode, 'off'>;
|
|
76
|
+
agent: TwinAgent | null;
|
|
77
|
+
at: number;
|
|
78
|
+
};
|
|
79
|
+
/** Refused by an API without turn waits (POST /wait with turn_after, 422): the twin polls instead. */
|
|
80
|
+
export declare class TurnWaitUnsupported extends Error {
|
|
81
|
+
}
|
|
64
82
|
export declare class TwinEngine {
|
|
65
83
|
#private;
|
|
66
84
|
constructor(deps: EngineDeps);
|
|
67
|
-
/**
|
|
85
|
+
/** Resolves when a pushed turn is waiting to land (the loop's sleep ends at once). */
|
|
86
|
+
pushed(): Promise<void>;
|
|
87
|
+
/** Stops every turn wait. */
|
|
88
|
+
stop(): void;
|
|
89
|
+
/**
|
|
90
|
+
* One pass; returns the suggested wait before the next. Turns pushed by the waits land first, without a listing;
|
|
91
|
+
* otherwise (and at least every 15 s, or every second when the API has no turn waits) every project is listed.
|
|
92
|
+
*/
|
|
68
93
|
tick(signal?: AbortSignal): Promise<number>;
|
|
69
94
|
/** Back online or awake: every conversation's retry backoff is cleared, so the next pass tries all of them. */
|
|
70
95
|
resetBackoff(): void;
|
|
71
96
|
landOne(c: TwinConversation, record: LocalConversation, mode: Exclude<LandingMode, 'off'>, agent: TwinAgent | null, signal?: AbortSignal): Promise<number | undefined>;
|
|
97
|
+
/** Several finished conversations for one branch: merged in their VMs at once, the files written once. */
|
|
98
|
+
landBatch(items: Ready[], signal?: AbortSignal): Promise<number | undefined>;
|
|
72
99
|
}
|
|
73
100
|
export interface LoopDeps {
|
|
74
101
|
tick(signal: AbortSignal): Promise<number>;
|
|
@@ -81,6 +108,8 @@ export interface LoopDeps {
|
|
|
81
108
|
network(): string;
|
|
82
109
|
/** Whether the API host answers now (a TCP connect, no request). */
|
|
83
110
|
reachable(): Promise<boolean>;
|
|
111
|
+
/** Resolves when a pushed turn waits to land: the loop's sleep ends at once. */
|
|
112
|
+
kicked?(): Promise<void>;
|
|
84
113
|
log(line: string): void;
|
|
85
114
|
}
|
|
86
115
|
/**
|
|
@@ -93,3 +122,4 @@ export declare function runTwinLoop(deps: LoopDeps, signal: AbortSignal): Promis
|
|
|
93
122
|
/** The device helper's twin. Nothing here asks the user anything. */
|
|
94
123
|
export declare function runWorktreeTwin(ctx: Ctx, device: string, signal: AbortSignal): Promise<void>;
|
|
95
124
|
export declare const TWIN_HANDLERS: Record<string, Handler>;
|
|
125
|
+
export {};
|
|
@@ -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;
|