@gleapai/kai-bridge 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runner/acp-runner.mjs +75 -24
- package/runner/lib/acp/harnesses.mjs +16 -4
- package/runner/lib/acp/mapper.mjs +38 -0
- package/runner/lib/contract.mjs +18 -1
- package/src/api.mjs +10 -0
- package/src/daemon.mjs +143 -10
- package/src/deps.mjs +26 -5
- package/src/executor.mjs +123 -20
- package/src/rehydration.mjs +129 -0
- package/src/repos.mjs +63 -0
- package/src/workspace.mjs +150 -24
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@gleapai/kai-bridge",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.12.1",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
package/package.json
CHANGED
package/runner/acp-runner.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
traceLog,
|
|
42
42
|
} from "./lib/contract.mjs";
|
|
43
43
|
import { deriveEngineSlug, getHarness, isNativeAnthropic, pickSessionConfigOptions, pickSessionMode, resolveHarnessId } from "./lib/acp/harnesses.mjs";
|
|
44
|
-
import { createAcpMapper, permissionPolicy } from "./lib/acp/mapper.mjs";
|
|
44
|
+
import { createAcpMapper, createReplayRecorder, permissionPolicy } from "./lib/acp/mapper.mjs";
|
|
45
45
|
import { describeProviderError, extractProviderError } from "./lib/acp/providerError.mjs";
|
|
46
46
|
import { aggregateUsageRows, lastRootContextSnapshot } from "./lib/acp/transcripts.mjs";
|
|
47
47
|
import { needsWireProxy, startWireProxy } from "./lib/wire-proxy.mjs";
|
|
@@ -60,6 +60,7 @@ const {
|
|
|
60
60
|
maxBudgetUsd: MAX_BUDGET_USD,
|
|
61
61
|
task: TASK,
|
|
62
62
|
feedback: FEEDBACK,
|
|
63
|
+
rehydratedTask: REHYDRATED_TASK,
|
|
63
64
|
questionAnswers: QUESTION_ANSWERS,
|
|
64
65
|
attachments: ATTACHMENTS,
|
|
65
66
|
customSystemPrompt: CUSTOM_SYSTEM_PROMPT,
|
|
@@ -76,7 +77,7 @@ const HARNESS_ID = resolveHarnessId(ARGS.argv.harness, MODEL);
|
|
|
76
77
|
const HARNESS = getHarness(HARNESS_ID);
|
|
77
78
|
const IS_ARTIFACT_WRITER = isArtifactWriterAgent(AGENT);
|
|
78
79
|
|
|
79
|
-
if (!TASK) {
|
|
80
|
+
if (!TASK && !REHYDRATED_TASK) {
|
|
80
81
|
emitSync({ type: "error", message: "runner: missing --task-b64 argument" });
|
|
81
82
|
process.exit(1);
|
|
82
83
|
}
|
|
@@ -244,10 +245,17 @@ async function downloadAttachments() {
|
|
|
244
245
|
return saved;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
function buildPrompt(savedAttachments = []) {
|
|
248
|
+
function buildPrompt(savedAttachments = [], { resumed = false } = {}) {
|
|
248
249
|
const sections = [];
|
|
249
250
|
const answers = renderAnswersBlock();
|
|
250
|
-
if (
|
|
251
|
+
if (REHYDRATED_TASK && !resumed) {
|
|
252
|
+
// A fresh session has no memory of the earlier turns, and the
|
|
253
|
+
// feedback alone reached a blank agent — including when the host
|
|
254
|
+
// asked for a resume that did not happen (no transcript on disk,
|
|
255
|
+
// session/resume failed). The host's rehydrated task already carries
|
|
256
|
+
// the task, plan, summary, recent turns, feedback and answers.
|
|
257
|
+
sections.push(REHYDRATED_TASK);
|
|
258
|
+
} else if (FEEDBACK) {
|
|
251
259
|
sections.push(FEEDBACK);
|
|
252
260
|
if (answers) sections.push(answers);
|
|
253
261
|
} else if (answers) {
|
|
@@ -535,6 +543,8 @@ async function main() {
|
|
|
535
543
|
let conn;
|
|
536
544
|
let acpSessionId = null;
|
|
537
545
|
let cancelRequested = null;
|
|
546
|
+
/** Set while a `session/load` replays the chat (createReplayRecorder). */
|
|
547
|
+
let replay = null;
|
|
538
548
|
|
|
539
549
|
const mcpServerIds = {};
|
|
540
550
|
for (const server of MCP_SERVERS || []) {
|
|
@@ -595,6 +605,8 @@ async function main() {
|
|
|
595
605
|
return { outcome: { outcome: "selected", optionId } };
|
|
596
606
|
},
|
|
597
607
|
async sessionUpdate(params) {
|
|
608
|
+
// The replay of a `session/load` is the chat's history, not this turn.
|
|
609
|
+
if (replay) return replay.record(params.update);
|
|
598
610
|
mapper.handleUpdate(params.update);
|
|
599
611
|
},
|
|
600
612
|
// AskUserQuestion arrives as a form elicitation (claude-agent-acp only
|
|
@@ -635,27 +647,60 @@ async function main() {
|
|
|
635
647
|
}
|
|
636
648
|
|
|
637
649
|
const mcpServers = HARNESS.sessionMcpServers(ctx, buildAcpMcpServers());
|
|
638
|
-
//
|
|
639
|
-
// disk — the FIRST turn of a session ships a session id too, and a
|
|
650
|
+
// Continue the prior session only when the harness can actually find it
|
|
651
|
+
// on disk — the FIRST turn of a session ships a session id too, and a
|
|
640
652
|
// resume against nothing fails after seconds of adapter retries.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
653
|
+
// `session/resume` picks the session up silently; an agent that can only
|
|
654
|
+
// reload one (Cursor: `loadSession`, no `sessionCapabilities.resume`)
|
|
655
|
+
// gets `session/load`, which replays the chat before it answers.
|
|
656
|
+
const agentCaps = init.agentCapabilities ?? {};
|
|
657
|
+
const continueVia = !SESSION_ID ? null : agentCaps.sessionCapabilities?.resume ? "resume" : agentCaps.loadSession === true ? "load" : null;
|
|
658
|
+
const canContinue = !!continueVia && (HARNESS.hasResumableSession ? HARNESS.hasResumableSession(ctx) : true);
|
|
645
659
|
// What the session's earlier turns already cost (Claude: the total the
|
|
646
660
|
// transcript saved — a resumed query's cost figure continues from it).
|
|
647
661
|
// Read before the resume appends this turn's record.
|
|
648
|
-
const priorTurnsCostUsd =
|
|
662
|
+
const priorTurnsCostUsd = canContinue ? Number(HARNESS.priorTurnsCostUsd?.(ctx)) || 0 : 0;
|
|
649
663
|
let sessionResponse;
|
|
650
|
-
|
|
651
|
-
|
|
664
|
+
/** The call that continued the harness session; null = a new session. */
|
|
665
|
+
let continuedVia = null;
|
|
666
|
+
/** What that `session/load` replayed (createReplayRecorder). */
|
|
667
|
+
let loaded = null;
|
|
668
|
+
if (canContinue && continueVia === "resume") {
|
|
652
669
|
try {
|
|
653
670
|
sessionResponse = await conn.resumeSession({ sessionId: SESSION_ID, cwd: WORK_DIR, mcpServers, _meta: HARNESS.sessionMeta(ctx) });
|
|
654
|
-
|
|
671
|
+
if (sessionResponse) continuedVia = "resume";
|
|
655
672
|
} catch (err) {
|
|
656
673
|
traceLog("resume.failed", { error: String(err?.message ?? err) });
|
|
657
674
|
}
|
|
658
|
-
}
|
|
675
|
+
} else if (canContinue) {
|
|
676
|
+
const recorder = createReplayRecorder();
|
|
677
|
+
replay = recorder;
|
|
678
|
+
try {
|
|
679
|
+
sessionResponse =
|
|
680
|
+
(await conn.loadSession({
|
|
681
|
+
sessionId: SESSION_ID,
|
|
682
|
+
cwd: WORK_DIR,
|
|
683
|
+
mcpServers,
|
|
684
|
+
...(ctx.additionalDirectories.length > 0 ? { additionalDirectories: ctx.additionalDirectories } : {}),
|
|
685
|
+
_meta: HARNESS.sessionMeta(ctx),
|
|
686
|
+
})) ?? {};
|
|
687
|
+
continuedVia = "load";
|
|
688
|
+
loaded = recorder;
|
|
689
|
+
} catch (err) {
|
|
690
|
+
traceLog("load.failed", { error: String(err?.message ?? err), replayed: recorder.updates });
|
|
691
|
+
}
|
|
692
|
+
// Belt and braces: the replay precedes the answer on the wire, and SDK
|
|
693
|
+
// 1.5 finishes its handlers before resuming us, but every notification
|
|
694
|
+
// runs its own async handler chain — one macrotask lets any still in
|
|
695
|
+
// flight land in the recorder before this turn's updates reach the mapper.
|
|
696
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
697
|
+
replay = null;
|
|
698
|
+
if (loaded) traceLog("load.replayed", { updates: loaded.updates, userMessages: loaded.userMessages.length });
|
|
699
|
+
}
|
|
700
|
+
// The agent has the earlier turns: a resumed session, or a loaded chat
|
|
701
|
+
// that replayed at least one of them. Cursor answers the load even when
|
|
702
|
+
// it cannot read the conversation back; such a chat has nothing to go on.
|
|
703
|
+
const resumed = continuedVia === "resume" || (continuedVia === "load" && loaded.userMessages.length > 0);
|
|
659
704
|
if (!sessionResponse) {
|
|
660
705
|
// The fallback must be a CLEAN start: `_meta` built with the resume id
|
|
661
706
|
// still in it makes claude-agent-acp re-run the query against the
|
|
@@ -667,13 +712,16 @@ async function main() {
|
|
|
667
712
|
_meta: HARNESS.sessionMeta({ ...ctx, resumeSessionId: null }),
|
|
668
713
|
});
|
|
669
714
|
}
|
|
670
|
-
//
|
|
671
|
-
// spec
|
|
672
|
-
// left every later call with `sessionId: undefined`
|
|
673
|
-
// and the whole resumed turn failed.
|
|
674
|
-
acpSessionId = sessionResponse.sessionId ?? (
|
|
715
|
+
// A continued session answers without an id — `session/load` per the ACP
|
|
716
|
+
// spec, codex-acp's resume too; it is the one we asked for. Reading
|
|
717
|
+
// `.sessionId` off it left every later call with `sessionId: undefined`
|
|
718
|
+
// → "Invalid params" and the whole resumed turn failed.
|
|
719
|
+
acpSessionId = sessionResponse.sessionId ?? (continuedVia ? SESSION_ID : null);
|
|
675
720
|
ctx.sessionId = acpSessionId;
|
|
676
|
-
|
|
721
|
+
// What the harness's promptPrefix keys on (below).
|
|
722
|
+
ctx.resumed = resumed;
|
|
723
|
+
ctx.priorUserMessages = loaded ? loaded.userMessages : null;
|
|
724
|
+
traceLog("session", { sessionId: acpSessionId, resumed, ...(continuedVia ? { via: continuedVia } : {}) });
|
|
677
725
|
startControlChannel({
|
|
678
726
|
isInFlight: () => inFlight,
|
|
679
727
|
steeringSupported,
|
|
@@ -714,10 +762,13 @@ async function main() {
|
|
|
714
762
|
// rules — shell redirections can still write).
|
|
715
763
|
const REVERTS_REPO = IS_ARTIFACT_WRITER;
|
|
716
764
|
const baselines = REVERTS_REPO ? captureRepoBaselines(WORK_DIR) : null;
|
|
717
|
-
// Harnesses without a system-prompt channel (Cursor)
|
|
718
|
-
//
|
|
765
|
+
// Harnesses without a system-prompt channel (Cursor) carry the persona in
|
|
766
|
+
// the prompt: in every new session — keyed on whether the session was
|
|
767
|
+
// actually continued (ctx.resumed), not on whether that was asked for —
|
|
768
|
+
// and in a continued one that does not hold it yet.
|
|
719
769
|
const promptPrefix = HARNESS.promptPrefix?.(ctx) || "";
|
|
720
|
-
|
|
770
|
+
if (REHYDRATED_TASK && !resumed && SESSION_ID) traceLog("resume.rehydrated", { sessionId: SESSION_ID });
|
|
771
|
+
const prompt = (promptPrefix ? `${promptPrefix}\n\n---\n\n` : "") + buildPrompt(savedAttachments, { resumed });
|
|
721
772
|
let stopReason = "end_turn";
|
|
722
773
|
let promptError = null;
|
|
723
774
|
try {
|
|
@@ -404,7 +404,9 @@ HARNESSES.cursor = {
|
|
|
404
404
|
* it runs on the user's Cursor login (or CURSOR_API_KEY); Gleap has no
|
|
405
405
|
* Cursor credentials, so the cloud never selects it. ACP v1 with
|
|
406
406
|
* loadSession + MCP over http/sse on session/new (probed 2026-08-23,
|
|
407
|
-
* build 2026.08.11).
|
|
407
|
+
* build 2026.08.11). No `sessionCapabilities.resume` (builds 2026.08.11
|
|
408
|
+
* and 2026.09.18 advertise only `list`), so follow-ups continue the chat
|
|
409
|
+
* through `session/load`. Binary is `cursor-agent` (legacy alias `agent`,
|
|
408
410
|
* which collides with other vendors' CLIs — resolve by explicit path).
|
|
409
411
|
*/
|
|
410
412
|
requiredEnv: (ctx) => (ctx.byoLogin ? null : "CURSOR_API_KEY"),
|
|
@@ -419,10 +421,20 @@ HARNESSES.cursor = {
|
|
|
419
421
|
// Mode ids come from the session/new response; first match wins.
|
|
420
422
|
sessionModePreference: (ctx) => (ctx.isPlanMode || ctx.isArtifactWriter ? ["plan", "ask", "read-only"] : ["agent", "default"]),
|
|
421
423
|
/**
|
|
422
|
-
* No system-prompt channel over ACP and no per-profile config dir:
|
|
423
|
-
*
|
|
424
|
+
* No system-prompt channel over ACP and no per-profile config dir: the
|
|
425
|
+
* persona travels as a prefix of the prompt instead. Every new chat gets
|
|
426
|
+
* it. A continued chat (`ctx.resumed`) already holds it from an earlier
|
|
427
|
+
* prompt, unless its replayed user messages (`ctx.priorUserMessages`,
|
|
428
|
+
* from `session/load`) lack these exact instructions: they changed since
|
|
429
|
+
* (a plan turn's guards give way to the build's git hand-off rule, edited
|
|
430
|
+
* project instructions) or never went in. Then it rides again.
|
|
424
431
|
*/
|
|
425
|
-
promptPrefix: (ctx) =>
|
|
432
|
+
promptPrefix: (ctx) => {
|
|
433
|
+
const instructions = ctx.appendSystemPrompt || "";
|
|
434
|
+
if (!ctx.resumed) return instructions;
|
|
435
|
+
const prior = ctx.priorUserMessages;
|
|
436
|
+
return Array.isArray(prior) && !prior.some((m) => m.includes(instructions)) ? instructions : "";
|
|
437
|
+
},
|
|
426
438
|
/** No transcript with a per-request split — context comes from usage_update; BYO bills nothing. */
|
|
427
439
|
collectTurnUsage: () => ({ path: null, rows: [], contextWindow: null }),
|
|
428
440
|
canonicalModel: (ctx, raw) => {
|
|
@@ -661,3 +661,41 @@ export function createAcpMapper({ emit, isPlanMode = false, onTurnShouldEnd, onC
|
|
|
661
661
|
},
|
|
662
662
|
};
|
|
663
663
|
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Holds what a `session/load` replays. An agent that can only reload a
|
|
667
|
+
* session (Cursor: `loadSession`, no `sessionCapabilities.resume`) streams
|
|
668
|
+
* the whole chat as `session/update`s before it answers — earlier turns'
|
|
669
|
+
* prompts, thoughts, text, tool calls, todos. None of it belongs to this
|
|
670
|
+
* turn: mapped, it would re-emit old rows and answers, and an old question
|
|
671
|
+
* or plan hand-off would end the turn. The runner records instead of
|
|
672
|
+
* mapping while the load is in flight. The chat's user messages are kept:
|
|
673
|
+
* they show whether a harness that carries its instructions in the prompt
|
|
674
|
+
* already sent them (Cursor's `promptPrefix`), and a load that replays
|
|
675
|
+
* none brought back no conversation to continue.
|
|
676
|
+
*/
|
|
677
|
+
export function createReplayRecorder() {
|
|
678
|
+
const userMessages = [];
|
|
679
|
+
let updates = 0;
|
|
680
|
+
let inUserMessage = false;
|
|
681
|
+
return {
|
|
682
|
+
record(update) {
|
|
683
|
+
updates += 1;
|
|
684
|
+
if (update?.sessionUpdate !== "user_message_chunk") {
|
|
685
|
+
inUserMessage = false;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// One message's chunks arrive back to back; any other update ends it.
|
|
689
|
+
const text = update.content?.type === "text" ? String(update.content.text ?? "") : "";
|
|
690
|
+
if (inUserMessage) userMessages[userMessages.length - 1] += text;
|
|
691
|
+
else userMessages.push(text);
|
|
692
|
+
inUserMessage = true;
|
|
693
|
+
},
|
|
694
|
+
get updates() {
|
|
695
|
+
return updates;
|
|
696
|
+
},
|
|
697
|
+
get userMessages() {
|
|
698
|
+
return userMessages;
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
}
|
package/runner/lib/contract.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// Zero npm dependencies — Node stdlib only.
|
|
21
21
|
|
|
22
22
|
import { spawnSync } from "node:child_process";
|
|
23
|
-
import { existsSync, readdirSync, statSync, writeSync } from "node:fs";
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeSync } from "node:fs";
|
|
24
24
|
import { basename, join } from "node:path";
|
|
25
25
|
|
|
26
26
|
// ── Argv parsing (no minimist; keep zero deps inside the sandbox) ─────
|
|
@@ -46,6 +46,16 @@ export function decodeB64(value) {
|
|
|
46
46
|
return Buffer.from(String(value), "base64").toString("utf8");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A text file the host wrote for this run; "" when absent or unreadable. */
|
|
50
|
+
export function readTextFile(path) {
|
|
51
|
+
if (path == null || path === true) return "";
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(String(path), "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export function decodeB64Json(value, fallback) {
|
|
50
60
|
if (value == null || value === true) return fallback;
|
|
51
61
|
try {
|
|
@@ -486,6 +496,13 @@ export function parseRunnerArgs(rawArgs) {
|
|
|
486
496
|
maxSteps,
|
|
487
497
|
task: decodeB64(argv["task-b64"]),
|
|
488
498
|
feedback: decodeB64(argv["feedback-b64"]),
|
|
499
|
+
// --rehydrated-task-file <path>: the host's self-contained prompt for
|
|
500
|
+
// a FRESH session (original task, plan, continuation summary, recent
|
|
501
|
+
// turns, latest message and answers in one) — sent instead of
|
|
502
|
+
// task / feedback / answers whenever the session did not resume. A
|
|
503
|
+
// file, not argv: it outgrows argv limits (32 KB for a whole Windows
|
|
504
|
+
// command line, 128 KB per Linux argument). See buildPrompt.
|
|
505
|
+
rehydratedTask: readTextFile(argv["rehydrated-task-file"]),
|
|
489
506
|
questionAnswers,
|
|
490
507
|
attachments,
|
|
491
508
|
customSystemPrompt: decodeB64(argv["system-prompt-b64"]),
|
package/src/api.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// POST /gleapcode/bridge/pair/poll → { status, device?, token? } (pollToken)
|
|
5
5
|
// PUT /gleapcode/bridge/devices/me/hello { name, platform, version, profiles, repos, roots }
|
|
6
6
|
// POST /gleapcode/bridge/devices/me/heartbeat { running: [turnIds] }
|
|
7
|
+
// POST /gleapcode/bridge/turns/:id/ack { via } → 410 when the turn already ended
|
|
7
8
|
// POST /gleapcode/bridge/turns/:id/events { events: [contract lines] }
|
|
8
9
|
// POST /gleapcode/bridge/turns/:id/result { result, changes, status }
|
|
9
10
|
// POST /gleapcode/bridge/devices/me/public-hosts { sessionId, services } → { domain, hosts, tunnel, displaced }
|
|
@@ -96,6 +97,15 @@ export class BridgeApi {
|
|
|
96
97
|
heartbeat(payload) {
|
|
97
98
|
return this.request("POST", "/gleapcode/bridge/devices/me/heartbeat", payload);
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* "This machine has the turn" — sent for every delivery of a
|
|
102
|
+
* `bridge.turn.start` (push, the Server's re-send, the pending poll); the
|
|
103
|
+
* Server stops re-sending once one lands. Short timeout: the turn does not
|
|
104
|
+
* wait on a slow Server. 410 = the turn ended before it got here.
|
|
105
|
+
*/
|
|
106
|
+
turnAck(turnId, payload) {
|
|
107
|
+
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/ack`, payload, { timeoutMs: 5_000 });
|
|
108
|
+
}
|
|
99
109
|
turnEvents(turnId, events) {
|
|
100
110
|
return this.request("POST", `/gleapcode/bridge/turns/${encodeURIComponent(turnId)}/events`, { events });
|
|
101
111
|
}
|
package/src/daemon.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
|
5
5
|
//
|
|
6
6
|
// Commands arrive on `private-bridge-<deviceId>` (Sockudo / Pusher
|
|
7
7
|
// protocol, the same channel family the dashboard uses):
|
|
8
|
-
// bridge.turn.start {
|
|
8
|
+
// bridge.turn.start { turnId, sessionId, profileId, repos:[{key, mode, base, carryUncommitted, mergedBranches}], resend?, ...AgentRunOpts }
|
|
9
|
+
// — acknowledged with POST /turns/:id/ack; the Server re-sends an
|
|
10
|
+
// unacknowledged start (`resend: n`), so a turn id seen before is ignored
|
|
9
11
|
// bridge.turn.cancel { turnId }
|
|
10
12
|
// bridge.turn.steer { turnId, steer: { id, text } } — inject into the running turn;
|
|
11
13
|
// answered with a {type:"steer", id, outcome} turn event
|
|
@@ -33,7 +35,7 @@ const PREWARM_TIMEOUT_MS = 15 * 60_000;
|
|
|
33
35
|
import { runTurn } from "./executor.mjs";
|
|
34
36
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
35
37
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
36
|
-
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
38
|
+
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, hasPriorWork, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
37
39
|
import { ServiceRunner, detectDevConfig, ensurePreviewBrowser, launchOptionsFor, loadPlaywright, preferredStablePort, previewMcpServer, readDevConfig } from "./preview.mjs";
|
|
38
40
|
import { PreviewError, previewErrorPayload, toPreviewErrorPayload } from "./preview-errors.mjs";
|
|
39
41
|
import { buildCloneCommand, collectCompanions, prefersSsh, resolveCompanionRemote } from "./companions.mjs";
|
|
@@ -57,6 +59,8 @@ const REALTIME_RETRY_MS = 15_000;
|
|
|
57
59
|
const HEARTBEAT_MS = 30_000;
|
|
58
60
|
/** Safety net under the realtime channel: ask the server for work it thinks we run (see pullPendingWork). */
|
|
59
61
|
const PENDING_POLL_MS = 60_000;
|
|
62
|
+
/** Turn ids remembered after they ran, so a late duplicate delivery never runs twice (see startTurn). */
|
|
63
|
+
const HANDLED_TURNS_MAX = 200;
|
|
60
64
|
const USAGE_REFRESH_MS = 10 * 60_000;
|
|
61
65
|
// Harness model catalogues change on releases, not by the minute.
|
|
62
66
|
const MODELS_REFRESH_MS = 6 * 60 * 60_000;
|
|
@@ -563,6 +567,42 @@ export class BridgeDaemon {
|
|
|
563
567
|
this.writeInflight(this.readInflight().filter((e) => e.turnId !== turnId));
|
|
564
568
|
}
|
|
565
569
|
|
|
570
|
+
get handledTurnsPath() {
|
|
571
|
+
return join(this.kaiHome, "state", "handled-turns.json");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Turn ids this machine already ran (newest last, capped). Kept on disk:
|
|
576
|
+
* a turn whose result was lost is still "running" on the Server, and the
|
|
577
|
+
* pending poll after a restart must not run it a second time.
|
|
578
|
+
*/
|
|
579
|
+
handledTurns() {
|
|
580
|
+
if (!this.handled) {
|
|
581
|
+
let ids = [];
|
|
582
|
+
try {
|
|
583
|
+
const raw = JSON.parse(readFileSync(this.handledTurnsPath, "utf8"));
|
|
584
|
+
if (Array.isArray(raw)) ids = raw.filter((id) => typeof id === "string");
|
|
585
|
+
} catch {
|
|
586
|
+
/* none yet */
|
|
587
|
+
}
|
|
588
|
+
this.handled = new Set(ids.slice(-HANDLED_TURNS_MAX));
|
|
589
|
+
}
|
|
590
|
+
return this.handled;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
rememberHandledTurn(turnId) {
|
|
594
|
+
const handled = this.handledTurns();
|
|
595
|
+
handled.delete(turnId);
|
|
596
|
+
handled.add(turnId);
|
|
597
|
+
while (handled.size > HANDLED_TURNS_MAX) handled.delete(handled.values().next().value);
|
|
598
|
+
try {
|
|
599
|
+
mkdirSync(join(this.kaiHome, "state"), { recursive: true });
|
|
600
|
+
writeFileSync(this.handledTurnsPath, JSON.stringify([...handled]));
|
|
601
|
+
} catch {
|
|
602
|
+
/* best effort */
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
566
606
|
/** Turns this machine was running when it was killed — report them dead. */
|
|
567
607
|
async reportInterruptedTurns() {
|
|
568
608
|
const entries = this.readInflight();
|
|
@@ -571,6 +611,8 @@ export class BridgeDaemon {
|
|
|
571
611
|
for (const entry of entries) {
|
|
572
612
|
const { turnId } = entry;
|
|
573
613
|
this.log("warn", "turn.interrupted", { turnId, agent: entry.agent });
|
|
614
|
+
// Reported failed below; a re-send or a poll must not start it over.
|
|
615
|
+
this.rememberHandledTurn(turnId);
|
|
574
616
|
await this.api
|
|
575
617
|
.turnResult(turnId, {
|
|
576
618
|
status: "failed",
|
|
@@ -867,8 +909,11 @@ export class BridgeDaemon {
|
|
|
867
909
|
}
|
|
868
910
|
for (const turn of pending?.turns || []) {
|
|
869
911
|
if (this.running.has(turn.turnId) || cancelled.has(String(turn.turnId))) continue;
|
|
912
|
+
// Ran here already and its result never landed: the Server's reaper
|
|
913
|
+
// settles it — running the work again is never the fix.
|
|
914
|
+
if (this.handledTurns().has(turn.turnId)) continue;
|
|
870
915
|
this.log("info", "turn.recovered", { turnId: turn.turnId, via });
|
|
871
|
-
void this.startTurn(turn).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
916
|
+
void this.startTurn(turn, { via }).catch((err) => this.log("error", "turn.recover.failed", { error: err.message }));
|
|
872
917
|
}
|
|
873
918
|
} catch (err) {
|
|
874
919
|
// Older server without the endpoint, or the server is restarting: the
|
|
@@ -931,7 +976,7 @@ export class BridgeDaemon {
|
|
|
931
976
|
.catch((err) => this.log("warn", "update.turn.refused.failed", { turnId: data.turnId, error: err?.message }));
|
|
932
977
|
return;
|
|
933
978
|
}
|
|
934
|
-
return this.startTurn(data);
|
|
979
|
+
return this.startTurn(data, { via: data?.resend ? "resend" : "push" });
|
|
935
980
|
case "bridge.turn.cancel":
|
|
936
981
|
this.running.get(data.turnId)?.ctrl.abort();
|
|
937
982
|
return;
|
|
@@ -1665,22 +1710,26 @@ export class BridgeDaemon {
|
|
|
1665
1710
|
}
|
|
1666
1711
|
|
|
1667
1712
|
/** Map the Server's repo bindings onto local checkouts; throw a readable error when one is missing. */
|
|
1668
|
-
async bindRepos(turn) {
|
|
1713
|
+
async bindRepos(turn, { onPrepare, signal } = {}) {
|
|
1669
1714
|
const bound = [];
|
|
1670
1715
|
for (const r of turn.repos || []) {
|
|
1716
|
+
if (signal?.aborted) break; // stopped mid-prep: the caller reports the cancel
|
|
1671
1717
|
const group = this.repoGroups.find((g) => g.key === r.key);
|
|
1672
1718
|
if (!group) throw new Error(`Repository ${r.key} is not checked out on this device.`);
|
|
1673
1719
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
1674
1720
|
const ws = await this.withGitAuth(r.key, (gitEnv) => materializeBinding({
|
|
1675
1721
|
kaiHome: this.kaiHome,
|
|
1676
1722
|
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1677
|
-
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
1723
|
+
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted, mergedBranches: r.mergedBranches },
|
|
1678
1724
|
sessionId: turn.sessionId,
|
|
1679
1725
|
title: turn.title,
|
|
1680
1726
|
gitEnv,
|
|
1727
|
+
onPrepare,
|
|
1681
1728
|
}));
|
|
1682
1729
|
bound.push({ key: r.key, ...ws });
|
|
1683
1730
|
if (ws.deps) this.log("info", "deps.seed", { repo: r.key, ...ws.deps });
|
|
1731
|
+
if (ws.restarted) this.log("info", "worktree.restarted", { turnId: turn.turnId, repo: r.key, from: ws.restarted.from, to: ws.branch });
|
|
1732
|
+
if (ws.restartSkipped) this.log("warn", "worktree.restart.skipped", { turnId: turn.turnId, repo: r.key, branch: ws.branch, error: ws.restartSkipped });
|
|
1684
1733
|
if (ws.fetch && (ws.fetch.stale || ws.fetch.attempts > 1)) this.log("warn", "workspace.fetch.contended", { repo: r.key, ...ws.fetch });
|
|
1685
1734
|
// Remember the choice per repo (the UI asks once, then sticks).
|
|
1686
1735
|
this.config.repoModes = { ...(this.config.repoModes || {}), [r.key]: mode };
|
|
@@ -1736,11 +1785,25 @@ export class BridgeDaemon {
|
|
|
1736
1785
|
return adopted;
|
|
1737
1786
|
}
|
|
1738
1787
|
|
|
1739
|
-
|
|
1788
|
+
/**
|
|
1789
|
+
* `via` = how the turn reached us: push, resend (the Server re-sent an
|
|
1790
|
+
* unacknowledged start), poll or reconnect (pullPendingWork).
|
|
1791
|
+
*/
|
|
1792
|
+
async startTurn(turn, { via = "push" } = {}) {
|
|
1740
1793
|
const { turnId } = turn;
|
|
1741
|
-
|
|
1794
|
+
// One turn, several deliveries by design: the Server re-sends a start
|
|
1795
|
+
// until it is acknowledged, and the poll / reconnect pull replays what
|
|
1796
|
+
// the Server still expects. Only the first delivery runs. Every one is
|
|
1797
|
+
// acknowledged — a duplicate usually means the first ack was missed.
|
|
1798
|
+
if (this.running.has(turnId) || this.handledTurns().has(turnId)) {
|
|
1799
|
+
this.log("info", "turn.duplicate", { turnId, via, running: this.running.has(turnId) });
|
|
1800
|
+
await this.ackTurn(turnId, via);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1742
1803
|
const ctrl = new AbortController();
|
|
1743
1804
|
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1805
|
+
// Claimed before the first await, so a second delivery arriving while
|
|
1806
|
+
// the ack is in flight is the duplicate above.
|
|
1744
1807
|
this.running.set(turnId, entry);
|
|
1745
1808
|
const releaseAwake = keepAwake();
|
|
1746
1809
|
let outcome = null;
|
|
@@ -1749,8 +1812,47 @@ export class BridgeDaemon {
|
|
|
1749
1812
|
this.rememberInflight(turnId, { sessionId: turn.sessionId, agent: turn.agent ?? null, harness: turn.harness ?? null, profileId: turn.profileId ?? null, startedAt: new Date().toISOString(), ...session });
|
|
1750
1813
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1751
1814
|
try {
|
|
1815
|
+
// Receipt first: the Server stops re-sending the start.
|
|
1816
|
+
if ((await this.ackTurn(turnId, via)) === "ended") {
|
|
1817
|
+
// Stopped or reaped before it reached this machine — nobody is
|
|
1818
|
+
// waiting for it, and its result would be refused anyway.
|
|
1819
|
+
this.log("info", "turn.ended_before_start", { turnId, via });
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1752
1822
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1753
|
-
|
|
1823
|
+
// Prep no longer blocks the daemon, so a Stop (or session close) can
|
|
1824
|
+
// land before the runner exists — and runTurn's abort listener would
|
|
1825
|
+
// never hear a signal that fired before it was added. Never start the
|
|
1826
|
+
// agent for a turn that was stopped on the way.
|
|
1827
|
+
const stoppedBeforeRun = () => {
|
|
1828
|
+
if (!ctrl.signal.aborted) return false;
|
|
1829
|
+
this.log("info", "turn.cancelled_before_run", { turnId });
|
|
1830
|
+
outcome = { status: "cancelled", changes: [], profileId: profile.id };
|
|
1831
|
+
return true;
|
|
1832
|
+
};
|
|
1833
|
+
if (stoppedBeforeRun()) return;
|
|
1834
|
+
// A fresh worktree costs a fetch, a checkout and a node_modules clone
|
|
1835
|
+
// — up to minutes for a big repo — while the dashboard already calls
|
|
1836
|
+
// the session running. Say what is happening before it starts (once
|
|
1837
|
+
// per turn; resumed worktrees and local checkouts skip it).
|
|
1838
|
+
let preparing = null;
|
|
1839
|
+
const onPrepare = () => {
|
|
1840
|
+
preparing ??= (async () => {
|
|
1841
|
+
batcher.push({ type: "tool_status", message: `Preparing workspace on ${this.config.device?.name || "this machine"}…` });
|
|
1842
|
+
await batcher.flush();
|
|
1843
|
+
})();
|
|
1844
|
+
return preparing;
|
|
1845
|
+
};
|
|
1846
|
+
const bound = await this.bindRepos(turn, { onPrepare, signal: ctrl.signal });
|
|
1847
|
+
if (stoppedBeforeRun()) return;
|
|
1848
|
+
// A merged worktree that kept its branch (see materializeBinding):
|
|
1849
|
+
// this turn's push lands on the merged branch again, so say why.
|
|
1850
|
+
for (const b of bound.filter((w) => w.restartSkipped)) {
|
|
1851
|
+
batcher.push({
|
|
1852
|
+
type: "text",
|
|
1853
|
+
message: `The pull request for ${b.key} merged, but uncommitted changes in ${b.cwd} conflict with the latest ${b.base}, so this turn continues on ${b.branch} and a new pull request may repeat the merged commits. Discard those changes to start from ${b.base} on the next turn.`,
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1754
1856
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1755
1857
|
// reachable as siblings under the same worktree root or by their
|
|
1756
1858
|
// local paths — the prompt lists them.
|
|
@@ -1764,12 +1866,25 @@ export class BridgeDaemon {
|
|
|
1764
1866
|
const live = await this.describeLivePreview(turn, bound, batcher);
|
|
1765
1867
|
const previewNote = live.note;
|
|
1766
1868
|
const mcpServers = live.hasLivePreview ? [...(turn.mcpServers || []), previewMcpServer(RUNNER_DIR)] : turn.mcpServers;
|
|
1869
|
+
// A follow-up that cannot resume its harness session is rehydrated
|
|
1870
|
+
// (executor.mjs), and its prompt says whether earlier turns' work is
|
|
1871
|
+
// already in the checkout — only a resumed worktree can hold any. A
|
|
1872
|
+
// restarted one starts from the base branch: its prompt names it
|
|
1873
|
+
// instead (its changes ship in a new pull request).
|
|
1874
|
+
const priorWork = await Promise.all(bound.filter((b) => b.mode === "worktree" && b.resumed && !b.restarted).map((b) => hasPriorWork(b.cwd, b.base)));
|
|
1875
|
+
if (stoppedBeforeRun()) return;
|
|
1767
1876
|
const res = await runTurn({
|
|
1768
|
-
turn: { ...turn,
|
|
1877
|
+
turn: { ...turn, mcpServers },
|
|
1878
|
+
// Kept out of the task itself: a rehydrated prompt retells the
|
|
1879
|
+
// task as "Original task", and these describe the workspace now.
|
|
1880
|
+
workspaceNote: `${repoNote}${previewNote}`,
|
|
1881
|
+
hasPriorBranch: priorWork.some(Boolean),
|
|
1882
|
+
restartedRepos: bound.filter((b) => b.restarted).map((b) => b.key),
|
|
1769
1883
|
profile,
|
|
1770
1884
|
workDir,
|
|
1771
1885
|
kaiHome: this.kaiHome,
|
|
1772
1886
|
signal: ctrl.signal,
|
|
1887
|
+
onRehydrate: ({ reason }) => this.log("info", "turn.rehydrated", { turnId, sessionId: turn.sessionId, reason }),
|
|
1773
1888
|
onSpawn: (handle) => {
|
|
1774
1889
|
entry.control = handle.control;
|
|
1775
1890
|
if (Number.isInteger(handle.pid)) this.rememberInflight(turnId, { pid: handle.pid });
|
|
@@ -1861,12 +1976,30 @@ export class BridgeDaemon {
|
|
|
1861
1976
|
.catch((err) => this.log("error", "result.lost", { turnId, error: err.message }));
|
|
1862
1977
|
}
|
|
1863
1978
|
this.forgetInflight(turnId);
|
|
1979
|
+
this.rememberHandledTurn(turnId);
|
|
1864
1980
|
releaseAwake();
|
|
1865
1981
|
this.running.delete(turnId);
|
|
1866
1982
|
if ((this.updatePending || this.restartPending) && this.running.size === 0) void this.checkForUpdate();
|
|
1867
1983
|
}
|
|
1868
1984
|
}
|
|
1869
1985
|
|
|
1986
|
+
/**
|
|
1987
|
+
* Tell the Server this machine has the turn. Resolves "acked", "ended"
|
|
1988
|
+
* (410: the turn is over — it must not run), or "unknown" (an older Server
|
|
1989
|
+
* without the route, a network hiccup): the turn then runs as it always
|
|
1990
|
+
* did. Never throws.
|
|
1991
|
+
*/
|
|
1992
|
+
async ackTurn(turnId, via) {
|
|
1993
|
+
try {
|
|
1994
|
+
await this.api.turnAck(turnId, { via });
|
|
1995
|
+
return "acked";
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
if (err?.status === 410) return "ended";
|
|
1998
|
+
this.log(err?.status === 404 ? "debug" : "warn", "turn.ack.failed", { turnId, via, error: err?.message });
|
|
1999
|
+
return "unknown";
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1870
2003
|
// ── preview browser ────────────────────────────────────────────────
|
|
1871
2004
|
/** Overridable seam (tests, embedded hosts): a browser the warm-up and the Playwright MCP can launch. */
|
|
1872
2005
|
ensurePreviewBrowser() {
|