larkin 0.4.1 → 0.4.5
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/README.md +76 -33
- package/artifacts/release/THIRD_PARTY_NOTICES.txt +218 -228
- package/assets/readme/larkin-github-cover.png +0 -0
- package/assets/readme/larkin-hero.svg +111 -0
- package/dist/agent/agent-state-store.cjs +193 -1
- package/dist/agent/agent-state-store.mjs +191 -0
- package/dist/app/agent-config.mjs +73 -6
- package/dist/app/agent-readiness.cjs +22 -2
- package/dist/app/agent-readiness.mjs +20 -2
- package/dist/app/binary-entry.mjs +6 -0
- package/dist/app/cli.mjs +7 -1
- package/dist/app/local-control.mjs +108 -2
- package/dist/app/run.mjs +8 -0
- package/dist/app/runtime-process.mjs +17 -1
- package/dist/app/session-cli.mjs +55 -20
- package/dist/dashboard/dashboard-view-model.mjs +12 -3
- package/dist/feishu/host-shell.cjs +104 -9
- package/dist/feishu/host-shell.mjs +105 -10
- package/dist/platform/config.cjs +406 -33
- package/dist/platform/config.mjs +405 -33
- package/dist/platform/process-boundary-trace.cjs +108 -0
- package/dist/platform/process-boundary-trace.mjs +102 -0
- package/dist/runtime/pi-artifact-provenance.cjs +147 -0
- package/dist/runtime/pi-artifact-provenance.mjs +139 -0
- package/dist/runtime/pi-compaction-recovery.cjs +703 -0
- package/dist/runtime/pi-compaction-recovery.mjs +679 -0
- package/dist/runtime/pi-model-catalog.cjs +2 -2
- package/dist/runtime/pi-model-catalog.mjs +2 -2
- package/dist/runtime/pi-profile-migration.cjs +556 -0
- package/dist/runtime/pi-profile-migration.mjs +515 -0
- package/dist/runtime/pi-provider-config.cjs +3 -3
- package/dist/runtime/pi-provider-config.mjs +3 -3
- package/dist/runtime/pi-rpc-client.cjs +39 -22
- package/dist/runtime/pi-rpc-client.mjs +39 -22
- package/dist/runtime/provider-error-classifier.cjs +39 -0
- package/dist/runtime/provider-error-classifier.mjs +34 -0
- package/dist/runtime/runtime-adapters.mjs +176 -27
- package/dist/runtime/runtime-host.cjs +630 -19
- package/dist/runtime/runtime-host.mjs +628 -18
- package/dist/runtime/runtime-readiness.cjs +6 -4
- package/dist/runtime/runtime-readiness.mjs +6 -4
- package/package.json +3 -3
|
@@ -3,6 +3,19 @@ function timestamp(value) {
|
|
|
3
3
|
const parsed = Date.parse(String(value || ""));
|
|
4
4
|
return Number.isFinite(parsed) ? parsed : null;
|
|
5
5
|
}
|
|
6
|
+
export function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
|
|
7
|
+
if (readiness?.state !== "ready")
|
|
8
|
+
return false;
|
|
9
|
+
const observedAt = timestamp(readiness.observedAt);
|
|
10
|
+
const epoch = timestamp(daemonStartedAt);
|
|
11
|
+
return observedAt !== null && epoch !== null && observedAt >= epoch;
|
|
12
|
+
}
|
|
13
|
+
export function isCurrentOwnedDaemon(daemon) {
|
|
14
|
+
return daemon?.state === "owned"
|
|
15
|
+
&& Number(daemon.pid) > 0
|
|
16
|
+
&& typeof daemon.processStartToken === "string" && daemon.processStartToken.length > 0
|
|
17
|
+
&& timestamp(daemon.startedAt) !== null;
|
|
18
|
+
}
|
|
6
19
|
export function isChannelReconnecting(status) {
|
|
7
20
|
const reconnectingAt = timestamp(status?.reconnectingAt);
|
|
8
21
|
const connectedAt = timestamp(status?.connectedAt);
|
|
@@ -12,9 +25,14 @@ export function isChannelReconnecting(status) {
|
|
|
12
25
|
}
|
|
13
26
|
export function projectAgentReadiness(input) {
|
|
14
27
|
const status = input.status ?? null;
|
|
15
|
-
const daemonOwned = daemonHasAgent(input.daemon, input.agentId);
|
|
28
|
+
const daemonOwned = isCurrentOwnedDaemon(input.daemon) && daemonHasAgent(input.daemon, input.agentId);
|
|
16
29
|
const daemonStartedAt = timestamp(input.daemon.startedAt);
|
|
17
30
|
const connectedAt = timestamp(status?.connectedAt);
|
|
31
|
+
// A resumed session deliberately retains its original creation time. Its
|
|
32
|
+
// current daemon-epoch proof is the fresh session observation, not that
|
|
33
|
+
// historic creation timestamp.
|
|
34
|
+
const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
|
|
35
|
+
const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
|
|
18
36
|
const channelConnected = daemonOwned
|
|
19
37
|
&& status?.connectedVia === "channel"
|
|
20
38
|
&& daemonStartedAt !== null
|
|
@@ -22,7 +40,7 @@ export function projectAgentReadiness(input) {
|
|
|
22
40
|
&& connectedAt >= daemonStartedAt;
|
|
23
41
|
const readiness = {
|
|
24
42
|
daemon_owned: daemonOwned,
|
|
25
|
-
runtime_ready: status?.runtimeReadiness
|
|
43
|
+
runtime_ready: daemonOwned && sessionCurrent && isRuntimeReadinessCurrent(status?.runtimeReadiness, input.daemon.startedAt),
|
|
26
44
|
channel_connected: channelConnected,
|
|
27
45
|
channel_not_reconnecting: !isChannelReconnecting(status),
|
|
28
46
|
};
|
|
@@ -55,6 +55,12 @@ async function dispatchInternal(mode, rest) {
|
|
|
55
55
|
process.title = "pi-rpc";
|
|
56
56
|
process.env.PI_CODING_AGENT = "true";
|
|
57
57
|
process.emitWarning = (() => { });
|
|
58
|
+
// The Pi package's normal Bun CLI registers OAuth loaders before starting.
|
|
59
|
+
// Larkin invokes its RPC entrypoint directly, so do the same here; otherwise
|
|
60
|
+
// an OAuth-backed compact falls back to a dynamic module import unavailable in
|
|
61
|
+
// a compiled standalone binary.
|
|
62
|
+
const { registerBunOAuthFlows } = await import("@earendil-works/pi-ai/bun-oauth");
|
|
63
|
+
registerBunOAuthFlows();
|
|
58
64
|
const { main: piMain } = await import("@earendil-works/pi-coding-agent");
|
|
59
65
|
const { invokeBuiltinPiRpc } = await import("../runtime/pi-inline-extensions.mjs");
|
|
60
66
|
await invokeBuiltinPiRpc(piMain, rest);
|
package/dist/app/cli.mjs
CHANGED
|
@@ -29,6 +29,7 @@ const routes = {
|
|
|
29
29
|
model: ["agent-config", "model"],
|
|
30
30
|
runtime: ["agent-config", "runtime"],
|
|
31
31
|
effort: ["agent-config", "effort"],
|
|
32
|
+
"pi-distribution": ["agent-config", "pi-distribution"],
|
|
32
33
|
chats: ["agent-config", "chats"],
|
|
33
34
|
config: ["agent-config", "config"],
|
|
34
35
|
session: ["session-cli"],
|
|
@@ -58,6 +59,9 @@ List every configured Agent and its current local status. Use --json for daemon/
|
|
|
58
59
|
Show or change an Agent model.`,
|
|
59
60
|
runtime: `Usage: larkin runtime [<runtime>] [--agent <App ID>] [--model <model>]
|
|
60
61
|
Show or change an Agent runtime.`,
|
|
62
|
+
"pi-distribution": `Usage: larkin pi-distribution [show|builtin|external] [--agent <App ID>] [--snapshot <private-file>] [--import-external-profile]
|
|
63
|
+
larkin pi-distribution rollback --snapshot <private-file>
|
|
64
|
+
Show or change one Pi Agent distribution. builtin requires configured provider state, or explicitly imports the external Pi 0.84.2 profile with --import-external-profile; all changes support config-lock CAS rollback.`,
|
|
61
65
|
effort: `Usage: larkin effort [<level>|clear|default] [--agent <App ID>]
|
|
62
66
|
Show or change an Agent reasoning effort; clear/default restores the Runtime default.`,
|
|
63
67
|
chats: `Usage: larkin chats [--agent <App ID>]
|
|
@@ -77,7 +81,8 @@ Examples:
|
|
|
77
81
|
|
|
78
82
|
Credentials, internal paths, serverId, activeAgent, and raw config are never exposed here.`,
|
|
79
83
|
session: `Usage: larkin session reset --agent <App ID> --json [--wait-ready <seconds>]
|
|
80
|
-
|
|
84
|
+
larkin session recover --agent <App ID> --reason context-overflow --json [--wait-ready <seconds>]
|
|
85
|
+
Reset replaces one idle, zero-backlog Runtime session. Recover is an explicit operator-only context-window recovery that preserves and replays canonical Inbox deliveries.`,
|
|
81
86
|
"pi-auth": `Usage: larkin pi-auth status [--agent <App ID>] [--json]
|
|
82
87
|
larkin pi-auth logout <provider> [--agent <App ID>]
|
|
83
88
|
Show non-sensitive official Pi credential metadata or remove one target provider credential.`,
|
|
@@ -124,6 +129,7 @@ Usage: larkin <command>
|
|
|
124
129
|
chats List known chats; use free/strict <oc_id> to configure mention requirements
|
|
125
130
|
config Inspect effective config/source, edit mention inheritance, or explicitly apply runtime changes
|
|
126
131
|
session reset Replace one idle, zero-backlog Agent Runtime session for a fresh scenario
|
|
132
|
+
session recover Explicitly recover a context-overflowed session and replay retained Inbox deliveries
|
|
127
133
|
pi-auth Show non-sensitive built-in Pi auth status or logout one provider
|
|
128
134
|
comment reply Reply to the exact cloud-document comment bound by a polled Inbox message
|
|
129
135
|
telemetry Inspect, export, import, or flush the durable OpenTelemetry trace queue
|
|
@@ -15,6 +15,48 @@ const AGENT_ID = /^cli_[A-Za-z0-9]+$/;
|
|
|
15
15
|
const OPERATION_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
|
16
16
|
const AUTHORIZATION = /^[A-Za-z0-9_-]{43,128}$/;
|
|
17
17
|
const UNIX_SOCKET_PATH_MAX_BYTES = process.platform === "darwin" ? 103 : 107;
|
|
18
|
+
function recoveryErrorText(code) {
|
|
19
|
+
switch (code) {
|
|
20
|
+
case "unknown_agent": return "unknown Agent";
|
|
21
|
+
case "agent_busy": return "Agent is not idle";
|
|
22
|
+
case "channel_unavailable": return "Runtime channel is unavailable";
|
|
23
|
+
case "channel_reconnecting": return "Runtime channel is reconnecting";
|
|
24
|
+
case "runtime_unavailable": return "Runtime readiness is unavailable";
|
|
25
|
+
case "recovery_unavailable": return "context-overflow recovery is unavailable";
|
|
26
|
+
case "recovery_staged_not_committed": return "context-overflow recovery was staged but not committed";
|
|
27
|
+
case "state_persistence_failed": return "context-overflow recovery committed but state persistence failed";
|
|
28
|
+
case "recovery_timeout": return "context-overflow recovery committed but readiness did not converge";
|
|
29
|
+
case "recovery_refused": return "context-overflow recovery was refused";
|
|
30
|
+
default: return "context-overflow recovery failed";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function sanitizeRecoveryReadiness(value) {
|
|
34
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
const candidate = value;
|
|
37
|
+
if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
|
|
38
|
+
|| !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
|
|
39
|
+
return undefined;
|
|
40
|
+
const runtime = candidate.runtime;
|
|
41
|
+
const state = candidate.state;
|
|
42
|
+
return { runtime, state,
|
|
43
|
+
...(state === "ready" ? {} : { reason: `Runtime readiness is ${state}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
|
|
44
|
+
}
|
|
45
|
+
function sanitizeSessionRecoveryResponse(response, agentId) {
|
|
46
|
+
const code = typeof response.code === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(response.code) ? response.code : undefined;
|
|
47
|
+
return {
|
|
48
|
+
ok: response.ok === true, agentId, ...(code ? { code } : {}),
|
|
49
|
+
...(code || response.error ? { error: recoveryErrorText(code) } : {}),
|
|
50
|
+
recoveryCommitted: response.recoveryCommitted === true, generationChanged: response.generationChanged === true,
|
|
51
|
+
sessionChanged: response.sessionChanged === true, turns: 0, runtimeReady: response.runtimeReady === true,
|
|
52
|
+
channelConnected: response.channelConnected === true, reconnecting: response.reconnecting === true,
|
|
53
|
+
pendingCount: Math.max(0, Number(response.pendingCount) || 0), rearmedCount: Math.max(0, Number(response.rearmedCount) || 0),
|
|
54
|
+
replayStatus: ["scheduled", "pending", "consumed", "not_started"].includes(response.replayStatus) ? response.replayStatus : "not_started",
|
|
55
|
+
remainingPendingCount: Math.max(0, Number(response.remainingPendingCount) || 0),
|
|
56
|
+
readyForFreshScenario: response.readyForFreshScenario === true, inboundObserved: false,
|
|
57
|
+
...(sanitizeRecoveryReadiness(response.readiness) ? { readiness: sanitizeRecoveryReadiness(response.readiness) } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
18
60
|
function socketPathsFit(root) {
|
|
19
61
|
if (isWindows)
|
|
20
62
|
return true; // Windows named-pipe socket paths use a much larger limit
|
|
@@ -92,7 +134,7 @@ function parseRequest(line) {
|
|
|
92
134
|
throw new Error("invalid agent control request");
|
|
93
135
|
}
|
|
94
136
|
if (value.operation === "session-reset") {
|
|
95
|
-
if (value.waitReadyMs !== undefined && (!Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
137
|
+
if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
96
138
|
throw new Error("invalid session reset waitReadyMs");
|
|
97
139
|
}
|
|
98
140
|
if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "waitReadyMs"].includes(key))) {
|
|
@@ -100,6 +142,17 @@ function parseRequest(line) {
|
|
|
100
142
|
}
|
|
101
143
|
return value;
|
|
102
144
|
}
|
|
145
|
+
if (value.operation === "session-recover") {
|
|
146
|
+
if (value.reason !== "context-overflow")
|
|
147
|
+
throw new Error("session recovery requires reason=context-overflow");
|
|
148
|
+
if (value.waitReadyMs !== undefined && (typeof value.waitReadyMs !== "number" || !Number.isSafeInteger(value.waitReadyMs) || value.waitReadyMs < 0 || value.waitReadyMs > 300_000)) {
|
|
149
|
+
throw new Error("invalid session recovery waitReadyMs");
|
|
150
|
+
}
|
|
151
|
+
if (Object.keys(value).some((key) => !["agentId", "authorization", "operation", "reason", "waitReadyMs"].includes(key))) {
|
|
152
|
+
throw new Error("session recovery control request 包含未知字段");
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
103
156
|
if (value.operation !== undefined || !OPERATION_ID.test(String(value.operationId || ""))) {
|
|
104
157
|
throw new Error("invalid agent upsert request");
|
|
105
158
|
}
|
|
@@ -498,13 +551,14 @@ export function createSupervisorControlServer({ larkinHome, authorityToken, ensu
|
|
|
498
551
|
},
|
|
499
552
|
};
|
|
500
553
|
}
|
|
501
|
-
export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, maxRememberedOperations = 256, }) {
|
|
554
|
+
export function createAgentControlServer({ larkinHome, authorityToken, upsert, resetSession, recoverSession, maxRememberedOperations = 256, }) {
|
|
502
555
|
let socket = "";
|
|
503
556
|
let socketRoot = "";
|
|
504
557
|
let socketIdentity = null;
|
|
505
558
|
const completed = new Map();
|
|
506
559
|
const inFlight = new Map();
|
|
507
560
|
const resetInFlight = new Map();
|
|
561
|
+
const recoveryInFlight = new Map();
|
|
508
562
|
const agentQueues = new Map();
|
|
509
563
|
const upsertConflict = (operationId, agentId) => ({
|
|
510
564
|
ok: false, operationId, agentId, code: "operation_conflict", error: "operationId 已绑定其他 Agent 或操作",
|
|
@@ -564,6 +618,52 @@ export function createAgentControlServer({ larkinHome, authorityToken, upsert, r
|
|
|
564
618
|
return;
|
|
565
619
|
}
|
|
566
620
|
if ("operation" in request) {
|
|
621
|
+
if (request.operation === "session-recover") {
|
|
622
|
+
const recoveryRequest = request;
|
|
623
|
+
let operation = recoveryInFlight.get(recoveryRequest.agentId);
|
|
624
|
+
if (!operation) {
|
|
625
|
+
const executeRecovery = async () => {
|
|
626
|
+
try {
|
|
627
|
+
if (!recoverSession)
|
|
628
|
+
throw new Error("session recovery control unavailable");
|
|
629
|
+
const result = await recoverSession({ agentId: recoveryRequest.agentId, reason: recoveryRequest.reason,
|
|
630
|
+
waitReadyMs: recoveryRequest.waitReadyMs ?? 30_000 });
|
|
631
|
+
return sanitizeSessionRecoveryResponse(result, recoveryRequest.agentId);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
const code = typeof error.code === "string"
|
|
635
|
+
? String(error.code)
|
|
636
|
+
: error instanceof RuntimePrerequisiteError && error.readiness.state === "unavailable"
|
|
637
|
+
? "runtime_unavailable" : "recovery_refused";
|
|
638
|
+
const projection = error;
|
|
639
|
+
return { ok: false, agentId: recoveryRequest.agentId, code,
|
|
640
|
+
error: recoveryErrorText(code), recoveryCommitted: false,
|
|
641
|
+
generationChanged: false, sessionChanged: false, turns: Math.max(0, Number(projection.turns) || 0),
|
|
642
|
+
runtimeReady: projection.runtimeReady === true, channelConnected: projection.channelConnected === true,
|
|
643
|
+
reconnecting: projection.reconnecting === true, pendingCount: Math.max(0, Number(projection.pendingCount) || 0),
|
|
644
|
+
rearmedCount: Math.max(0, Number(projection.rearmedCount) || 0), replayStatus: "not_started",
|
|
645
|
+
remainingPendingCount: Math.max(0, Number(projection.remainingPendingCount) || 0),
|
|
646
|
+
readyForFreshScenario: false, inboundObserved: false,
|
|
647
|
+
...(error instanceof RuntimePrerequisiteError ? { readiness: sanitizeRecoveryReadiness(error.readiness) } : {}) };
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const prior = agentQueues.get(recoveryRequest.agentId) ?? Promise.resolve();
|
|
651
|
+
const executing = prior.catch(() => { }).then(executeRecovery);
|
|
652
|
+
let queued;
|
|
653
|
+
queued = executing.finally(() => {
|
|
654
|
+
if (agentQueues.get(recoveryRequest.agentId) === queued)
|
|
655
|
+
agentQueues.delete(recoveryRequest.agentId);
|
|
656
|
+
if (recoveryInFlight.get(recoveryRequest.agentId) === queued)
|
|
657
|
+
recoveryInFlight.delete(recoveryRequest.agentId);
|
|
658
|
+
});
|
|
659
|
+
agentQueues.set(recoveryRequest.agentId, queued);
|
|
660
|
+
recoveryInFlight.set(recoveryRequest.agentId, queued);
|
|
661
|
+
operation = queued;
|
|
662
|
+
}
|
|
663
|
+
const response = await operation;
|
|
664
|
+
connection.end(`${JSON.stringify(response)}\n`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
567
667
|
const resetRequest = request;
|
|
568
668
|
let operation = resetInFlight.get(resetRequest.agentId);
|
|
569
669
|
if (!operation) {
|
|
@@ -776,6 +876,12 @@ export async function requestSessionReset({ larkinHome, agentId, waitReadyMs = 3
|
|
|
776
876
|
request: { operation: "session-reset", agentId, waitReadyMs },
|
|
777
877
|
});
|
|
778
878
|
}
|
|
879
|
+
export async function requestSessionRecovery({ larkinHome, agentId, reason = "context-overflow", waitReadyMs = 30_000, }) {
|
|
880
|
+
return requestAgentControl({
|
|
881
|
+
larkinHome, timeoutMs: Math.max(1_000, waitReadyMs + 1_000),
|
|
882
|
+
request: { operation: "session-recover", agentId, reason, waitReadyMs },
|
|
883
|
+
});
|
|
884
|
+
}
|
|
779
885
|
export function cleanupStaleAgentControlSocket(larkinHome, expectedToken) {
|
|
780
886
|
const authority = secureAuthority(larkinHome);
|
|
781
887
|
if (!sameSecret(authority.token, expectedToken))
|
package/dist/app/run.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { acquireProcessLock, readProcessState, terminateOwnedProcess, waitForPro
|
|
|
11
11
|
import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
|
|
12
12
|
import { cleanupStaleAgentControlSocket, createSupervisorControlServer, initializeControlAuthority, removeControlAuthority, requestDashboardRecovery, } from "./local-control.mjs";
|
|
13
13
|
import { internalCommandSpec, processCommandToken } from "./internal-command.mjs";
|
|
14
|
+
import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
|
|
14
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const die = (message) => { console.error(`✗ ${message}`); process.exit(1); };
|
|
16
17
|
export function selectedAgentIds(argv, available) {
|
|
@@ -63,8 +64,11 @@ export async function stopDashboardWithinBound(child, configDir, signal = "SIGTE
|
|
|
63
64
|
return await waitForChildExit(child, Math.max(250, Math.min(2_000, timeoutMs))) ? "killed" : "unowned";
|
|
64
65
|
}
|
|
65
66
|
export async function main() {
|
|
67
|
+
process.env.LARKIN_DAEMON_EPOCH ||= new Date().toISOString();
|
|
66
68
|
const argv = process.argv.slice(2);
|
|
67
69
|
const { configDir, file: configFile, config } = larkinConfig.loadConfig(process.env);
|
|
70
|
+
for (const agent of Object.values(config.agents))
|
|
71
|
+
traceProcessBoundary(process.env, "supervisor:config-loaded", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
|
|
68
72
|
if (!fs.existsSync(configFile))
|
|
69
73
|
die(`没找到配置 ${configFile},先跑 larkin setup`);
|
|
70
74
|
if (!config.serverId || !Object.keys(config.agents).length)
|
|
@@ -193,6 +197,8 @@ export async function main() {
|
|
|
193
197
|
};
|
|
194
198
|
if (argv.includes("--dry-run"))
|
|
195
199
|
runtimeEnv.LARKIN_FEISHU_DRYRUN = "1";
|
|
200
|
+
for (const agent of agents)
|
|
201
|
+
traceProcessBoundary(runtimeEnv, "supervisor:daemon-env-prepared", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId) });
|
|
196
202
|
let stopping = false;
|
|
197
203
|
let dashboard = null;
|
|
198
204
|
// Set when the daemon-restart path stops the dashboard on purpose (port
|
|
@@ -218,6 +224,8 @@ export async function main() {
|
|
|
218
224
|
// external process manager (launchd) takes over as the final respawn layer.
|
|
219
225
|
const launchDaemon = () => {
|
|
220
226
|
daemon = spawn(daemonSpec.command, daemonSpec.args, { env: runtimeEnv, stdio: "inherit" });
|
|
227
|
+
for (const agent of agents)
|
|
228
|
+
traceProcessBoundary(runtimeEnv, "supervisor:daemon-spawned", { configDir, agentId: agent.agentId, targetDir: path.join(configDir, "providers", "pi", agent.agentId), childPid: daemon.pid ?? null });
|
|
221
229
|
daemon.once("exit", (code, signal) => {
|
|
222
230
|
if (stopping) {
|
|
223
231
|
resolveDaemonFinal?.({ code, signal });
|
|
@@ -7,6 +7,7 @@ import { createNativeRuntimeAdapter } from "../runtime/runtime-adapters.mjs";
|
|
|
7
7
|
import { createRuntimeHost } from "../runtime/runtime-host.mjs";
|
|
8
8
|
import { createAgentStateStore } from "../agent/agent-state-store.mjs";
|
|
9
9
|
import { loadConfig, markConfigApplied, runtimeConfigSignature } from "../platform/config.mjs";
|
|
10
|
+
import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
|
|
10
11
|
import { createAgentControlServer } from "./local-control.mjs";
|
|
11
12
|
import { hydrateRuntimeAgent, syncAgentProfile } from "./runtime-agent-config.mjs";
|
|
12
13
|
import { loadTelemetryConfig } from "../platform/telemetry-config.mjs";
|
|
@@ -53,6 +54,7 @@ export async function main(env = process.env, overrides = {}) {
|
|
|
53
54
|
channelPackage = await import(pathToFileURL(path.resolve(env.LARKIN_TEST_CHANNEL_MODULE)).href);
|
|
54
55
|
}
|
|
55
56
|
const adapters = new Map();
|
|
57
|
+
env.LARKIN_DAEMON_EPOCH ||= new Date().toISOString();
|
|
56
58
|
const runtimeHost = overrides.runtimeHost ?? createRuntimeHost({
|
|
57
59
|
promptBuilder: new ContextPromptBuilder(),
|
|
58
60
|
stateStoreFor(agentId) {
|
|
@@ -91,6 +93,9 @@ export async function main(env = process.env, overrides = {}) {
|
|
|
91
93
|
throw new Error("LARKIN_HOME/LARKIN_CONFIG_DIR required");
|
|
92
94
|
if (!env.LARKIN_CONTROL_AUTHORIZATION)
|
|
93
95
|
throw new Error("LARKIN_CONTROL_AUTHORIZATION required");
|
|
96
|
+
const loaded = loadConfig(env);
|
|
97
|
+
for (const agent of Object.values(loaded.config.agents))
|
|
98
|
+
traceProcessBoundary(env, "daemon:config-loaded", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId) });
|
|
94
99
|
controlServer = createAgentControlServer({
|
|
95
100
|
larkinHome: env.LARKIN_HOME,
|
|
96
101
|
authorityToken: env.LARKIN_CONTROL_AUTHORIZATION,
|
|
@@ -104,9 +109,20 @@ export async function main(env = process.env, overrides = {}) {
|
|
|
104
109
|
const result = await hostShell.resetSession(request.agentId, request.waitReadyMs);
|
|
105
110
|
return { ok: result.readyForFreshScenario, agentId: request.agentId, ...result };
|
|
106
111
|
},
|
|
112
|
+
async recoverSession(request) {
|
|
113
|
+
const result = await hostShell.recoverSession(request.agentId, request.reason, request.waitReadyMs);
|
|
114
|
+
return { ok: result.recoveryCommitted, agentId: request.agentId, ...result };
|
|
115
|
+
},
|
|
107
116
|
});
|
|
108
117
|
await controlServer.start();
|
|
109
|
-
|
|
118
|
+
for (const agent of hostShell.agents)
|
|
119
|
+
traceProcessBoundary(env, "daemon:before-runtime-ready", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId) });
|
|
120
|
+
const runtimeStart = hostShell.start().catch((error) => {
|
|
121
|
+
for (const agent of hostShell.agents)
|
|
122
|
+
traceProcessBoundary(env, "daemon:runtime-ready-failure", { configDir: loaded.configDir, agentId: agent.agentId, targetDir: path.join(loaded.configDir, "providers", "pi", agent.agentId), error });
|
|
123
|
+
throw error;
|
|
124
|
+
});
|
|
125
|
+
await markConfigAppliedAfterRuntimeReady(env, hostShell.agents, runtimeStart);
|
|
110
126
|
}
|
|
111
127
|
if (path.resolve(process.argv[1] || "") === path.resolve(fileURLToPath(import.meta.url))) {
|
|
112
128
|
main().catch((error) => { console.error(`✗ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });
|
package/dist/app/session-cli.mjs
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { loadConfig } from "../platform/config.mjs";
|
|
2
|
-
import { requestSessionReset } from "./local-control.mjs";
|
|
3
|
-
function
|
|
4
|
-
if (
|
|
2
|
+
import { requestSessionRecovery, requestSessionReset } from "./local-control.mjs";
|
|
3
|
+
function sanitizeRecoveryReadiness(value) {
|
|
4
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5
|
+
return undefined;
|
|
6
|
+
const candidate = value;
|
|
7
|
+
if (!["codex", "claude", "pi"].includes(String(candidate.runtime))
|
|
8
|
+
|| !["missing", "unauthenticated", "unavailable", "incompatible", "ready"].includes(String(candidate.state)))
|
|
9
|
+
return undefined;
|
|
10
|
+
return { runtime: candidate.runtime, state: candidate.state,
|
|
11
|
+
...(candidate.state === "ready" ? {} : { reason: `Runtime readiness is ${String(candidate.state)}.`, nextAction: "Inspect Runtime/provider configuration, then retry." }) };
|
|
12
|
+
}
|
|
13
|
+
function parseSessionArguments(args) {
|
|
14
|
+
if (args[0] !== "reset" && args[0] !== "recover")
|
|
5
15
|
throw new Error("unsupported session subcommand");
|
|
16
|
+
const operation = args[0];
|
|
6
17
|
const values = new Map();
|
|
7
18
|
let json = false;
|
|
8
19
|
for (let index = 1; index < args.length; index += 1) {
|
|
@@ -13,7 +24,7 @@ function parseResetArguments(args) {
|
|
|
13
24
|
json = true;
|
|
14
25
|
continue;
|
|
15
26
|
}
|
|
16
|
-
if (!["--agent", "--wait-ready"].includes(token)) {
|
|
27
|
+
if (!["--agent", "--wait-ready", "--reason"].includes(token)) {
|
|
17
28
|
throw new Error(token.startsWith("-") ? `unknown flag: ${token}` : `unexpected positional: ${token}`);
|
|
18
29
|
}
|
|
19
30
|
if (values.has(token))
|
|
@@ -32,7 +43,12 @@ function parseResetArguments(args) {
|
|
|
32
43
|
const waitSeconds = Number(values.get("--wait-ready") ?? "30");
|
|
33
44
|
if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 300)
|
|
34
45
|
throw new Error("--wait-ready must be 0..300 seconds");
|
|
35
|
-
|
|
46
|
+
const reason = values.get("--reason");
|
|
47
|
+
if (operation === "recover" && reason !== "context-overflow")
|
|
48
|
+
throw new Error("--reason context-overflow is required for session recover");
|
|
49
|
+
if (operation === "reset" && reason !== undefined)
|
|
50
|
+
throw new Error("--reason is only valid for session recover");
|
|
51
|
+
return { operation, agentId, waitReadyMs: Math.round(waitSeconds * 1000), ...(reason ? { reason: reason } : {}) };
|
|
36
52
|
}
|
|
37
53
|
export async function runSessionCli(args, env = process.env, dependencies = {}) {
|
|
38
54
|
const io = dependencies.io ?? { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) };
|
|
@@ -42,29 +58,46 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
|
|
|
42
58
|
};
|
|
43
59
|
let parsed;
|
|
44
60
|
try {
|
|
45
|
-
parsed =
|
|
61
|
+
parsed = parseSessionArguments(args);
|
|
46
62
|
}
|
|
47
63
|
catch (error) {
|
|
48
64
|
return fail("invalid_arguments", error instanceof Error ? error.message : String(error));
|
|
49
65
|
}
|
|
50
66
|
const agentId = parsed.agentId;
|
|
51
67
|
if (typeof env.LARKIN_AGENT_ID === "string" && env.LARKIN_AGENT_ID.trim()) {
|
|
52
|
-
return fail("user_authority_required",
|
|
68
|
+
return fail("user_authority_required", parsed.operation === "reset"
|
|
69
|
+
? "session reset is available only from a user terminal"
|
|
70
|
+
: "session recovery is available only from a user terminal", agentId);
|
|
53
71
|
}
|
|
54
72
|
try {
|
|
55
73
|
const loaded = loadConfig(env);
|
|
56
|
-
const result =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const result = parsed.operation === "recover"
|
|
75
|
+
? await (dependencies.requestRecovery ?? requestSessionRecovery)({
|
|
76
|
+
larkinHome: loaded.config.larkinHome, agentId, reason: parsed.reason, waitReadyMs: parsed.waitReadyMs,
|
|
77
|
+
})
|
|
78
|
+
: await (dependencies.request ?? requestSessionReset)({
|
|
79
|
+
larkinHome: loaded.config.larkinHome, agentId, waitReadyMs: parsed.waitReadyMs,
|
|
80
|
+
});
|
|
81
|
+
const output = "recoveryCommitted" in result
|
|
82
|
+
? {
|
|
83
|
+
ok: result.ok, agent_id: result.agentId, recovery_committed: result.recoveryCommitted,
|
|
84
|
+
generation_changed: result.generationChanged, session_changed: result.sessionChanged, turns: result.turns,
|
|
85
|
+
runtime_ready: result.runtimeReady, channel_connected: result.channelConnected, reconnecting: result.reconnecting,
|
|
86
|
+
pending_count: result.pendingCount, rearmed_count: result.rearmedCount, replay_status: result.replayStatus,
|
|
87
|
+
remaining_pending_count: result.remainingPendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
|
|
88
|
+
inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
|
|
89
|
+
...(result.error ? { error: "context-overflow recovery failed" } : {}),
|
|
90
|
+
...(sanitizeRecoveryReadiness(result.readiness) ? { readiness: sanitizeRecoveryReadiness(result.readiness) } : {}),
|
|
91
|
+
}
|
|
92
|
+
: {
|
|
93
|
+
ok: result.ok, agent_id: result.agentId,
|
|
94
|
+
reset_committed: result.resetCommitted, generation_changed: result.generationChanged,
|
|
95
|
+
session_changed: result.sessionChanged, turns: result.turns, runtime_ready: result.runtimeReady,
|
|
96
|
+
channel_connected: result.channelConnected, reconnecting: result.reconnecting,
|
|
97
|
+
pending_count: result.pendingCount, ready_for_fresh_scenario: result.readyForFreshScenario,
|
|
98
|
+
inbound_observed: result.inboundObserved, ...(result.code ? { code: result.code } : {}),
|
|
99
|
+
...(result.error ? { error: result.error } : {}), ...(result.readiness ? { readiness: result.readiness } : {}),
|
|
100
|
+
};
|
|
68
101
|
io.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
69
102
|
return result.ok ? 0 : 1;
|
|
70
103
|
}
|
|
@@ -72,7 +105,9 @@ export async function runSessionCli(args, env = process.env, dependencies = {})
|
|
|
72
105
|
const message = error instanceof Error ? error.message : String(error);
|
|
73
106
|
const output = {
|
|
74
107
|
ok: false, agent_id: agentId,
|
|
75
|
-
code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable",
|
|
108
|
+
code: /timeout/i.test(message) ? "control_timeout" : "control_unavailable",
|
|
109
|
+
error: parsed.operation === "recover"
|
|
110
|
+
? (/timeout/i.test(message) ? "session recovery control timed out" : "session recovery control unavailable") : message,
|
|
76
111
|
};
|
|
77
112
|
io.stdout(`${JSON.stringify(output, null, 2)}\n`);
|
|
78
113
|
return 1;
|
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { StringDecoder } from "node:string_decoder";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { readJson, readProcessState } from "../platform/process-state.mjs";
|
|
10
|
+
import { isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
|
|
10
11
|
import { isAllowedDashboardAvatarUrl } from "./dashboard-avatar.mjs";
|
|
11
12
|
import { collectWorkspaceEntry as collectTypedWorkspaceEntry } from "./dashboard-workspace.mjs";
|
|
12
13
|
import { buildFingerprint, packageVersion } from "../platform/build-info.mjs";
|
|
@@ -429,7 +430,7 @@ export function projectStatusTimeline(status) {
|
|
|
429
430
|
].sort((x, y) => new Date(String(y.at)).getTime() - new Date(String(x.at)).getTime()).slice(0, 30);
|
|
430
431
|
return { activityLog, deliverLog, recentErrors, lastActivity, lastDeliver, feed };
|
|
431
432
|
}
|
|
432
|
-
async function collectAgentStatus(a, configDir, piModelResolver) {
|
|
433
|
+
async function collectAgentStatus(a, configDir, daemonStartedAt, piModelResolver) {
|
|
433
434
|
const dir = a.stateDir;
|
|
434
435
|
const botIdentity = readJson(path.join(dir, "bot-identity.json"), null);
|
|
435
436
|
const status = readJson(path.join(dir, "status.json"), {});
|
|
@@ -476,6 +477,14 @@ async function collectAgentStatus(a, configDir, piModelResolver) {
|
|
|
476
477
|
}
|
|
477
478
|
// 本 agent 专属时间线:三路历史合并按时间倒序,不是全局一条汇总——每个 agent 只看自己的故事。
|
|
478
479
|
const { recentErrors, lastActivity, lastDeliver, feed } = projectStatusTimeline(status);
|
|
480
|
+
const statusReadiness = status.runtimeReadiness;
|
|
481
|
+
const sessionStartedAt = finiteTime(status.session?.startedAt);
|
|
482
|
+
const daemonEpoch = finiteTime(daemonStartedAt);
|
|
483
|
+
const sessionCurrent = sessionStartedAt !== null && daemonEpoch !== null && sessionStartedAt >= daemonEpoch;
|
|
484
|
+
const runtimeReadiness = sessionCurrent && isRuntimeReadinessCurrent(statusReadiness, daemonStartedAt)
|
|
485
|
+
|| statusReadiness?.state !== "ready"
|
|
486
|
+
? statusReadiness || null
|
|
487
|
+
: { ...statusReadiness, state: "unavailable", reason: "Runtime readiness evidence is stale for the current daemon epoch." };
|
|
479
488
|
// 提醒:不只给数量,给完整列表——待触发按最近到期优先排前面,已触发/已取消按最近的排在后面。
|
|
480
489
|
const pendingReminders = reminders.filter((r) => r.status === "scheduled" || r.status === "pending")
|
|
481
490
|
.sort((x, y) => new Date(String(x.fireAt)).getTime() - new Date(String(y.fireAt)).getTime());
|
|
@@ -492,7 +501,7 @@ async function collectAgentStatus(a, configDir, piModelResolver) {
|
|
|
492
501
|
runtime: a.runtime,
|
|
493
502
|
model: status.session?.runtime === a.runtime && status.session?.model ? String(status.session.model) : a.model,
|
|
494
503
|
effort: status.session?.runtime === a.runtime && status.session?.reasoningEffort ? String(status.session.reasoningEffort) : a.effort || null,
|
|
495
|
-
runtimeReadiness
|
|
504
|
+
runtimeReadiness,
|
|
496
505
|
credentialReady: fs.existsSync(path.join(configDir, "bots", `${a.feishuProfile}.json`)),
|
|
497
506
|
bot: botIdentity ? {
|
|
498
507
|
name: botIdentity.name,
|
|
@@ -542,7 +551,7 @@ export async function collectStatus(options = {}) {
|
|
|
542
551
|
uptimeSec: daemonStatus?.startedAt ? ageSec(daemonStatus.startedAt) : null,
|
|
543
552
|
agents: Array.isArray(daemonStatus?.agents) ? daemonStatus.agents.filter((id) => typeof id === "string") : [],
|
|
544
553
|
};
|
|
545
|
-
const statuses = (await Promise.all(agents.map((a) => collectAgentStatus(a, configDir, options.piModelResolver)))).map((agent) => {
|
|
554
|
+
const statuses = (await Promise.all(agents.map((a) => collectAgentStatus(a, configDir, daemon.startedAt, options.piModelResolver)))).map((agent) => {
|
|
546
555
|
return { ...agent, ...projectAgentHealth(agent, daemon) };
|
|
547
556
|
});
|
|
548
557
|
return {
|