larkin 0.4.18 → 0.4.19

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.
@@ -0,0 +1,183 @@
1
+ // @bun
2
+ // src/runtime/pi-subagent-ledger.ts
3
+ import fs from "fs";
4
+ import path from "path";
5
+ var PI_SUBAGENT_RECORD_DIRNAME = "pi-subagent-records";
6
+ var PI_SUBAGENT_SESSION_OWNER_ENV = "LARKIN_PI_SESSION_OWNER";
7
+ var TERMINAL_STATUSES = new Set([
8
+ "completed",
9
+ "failed",
10
+ "cancelled",
11
+ "timed_out",
12
+ "orphaned"
13
+ ]);
14
+ function isTerminalPiSubagentRecord(record) {
15
+ if (!record || typeof record !== "object")
16
+ return false;
17
+ const status = record.status;
18
+ return typeof status === "string" && status.length > 0 && status !== "running" && status !== "queued";
19
+ }
20
+ function isConsumedTerminalPiSubagentRecord(record) {
21
+ if (!isTerminalPiSubagentRecord(record))
22
+ return false;
23
+ return record.resultConsumed === true;
24
+ }
25
+ function ledgerStatusFromPiSubagentRecord(record) {
26
+ const status = typeof record === "object" && record && "status" in record ? String(record.status || "").trim().toLowerCase() : "";
27
+ if (status === "error")
28
+ return "failed";
29
+ if (status === "aborted")
30
+ return "timed_out";
31
+ if (status === "stopped")
32
+ return "cancelled";
33
+ if (status === "steered")
34
+ return "timed_out";
35
+ return "completed";
36
+ }
37
+ function readPersistedPiSubagentTerminal(file) {
38
+ return readPiSubagentTerminalFile(file, false);
39
+ }
40
+ function writePersistedPiSubagentTerminal(file, input, options) {
41
+ writePiSubagentTerminalFile(file, input, options?.resultConsumed === true);
42
+ }
43
+ function dispatchedSubagentRecordDir(stateDir) {
44
+ return path.join(stateDir, PI_SUBAGENT_RECORD_DIRNAME);
45
+ }
46
+ function sweepAbsentPiSubagentRecordFiles(recordDir, getRecord, options) {
47
+ let names;
48
+ try {
49
+ names = fs.readdirSync(recordDir);
50
+ } catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return [];
53
+ throw error;
54
+ }
55
+ const removed = [];
56
+ for (const name of names) {
57
+ if (!name || name.startsWith("."))
58
+ continue;
59
+ const file = path.join(recordDir, name);
60
+ try {
61
+ const stat = fs.lstatSync(file);
62
+ if (stat.isSymbolicLink() || !stat.isFile())
63
+ continue;
64
+ } catch {
65
+ continue;
66
+ }
67
+ const owner = typeof options?.owner === "string" && options.owner.trim() ? options.owner.trim() : undefined;
68
+ if (owner && readPiSubagentRecordOwner(file) !== owner)
69
+ continue;
70
+ const record = getRecord(name);
71
+ if (isTerminalPiSubagentRecord(record)) {
72
+ try {
73
+ writePersistedPiSubagentTerminal(file, {
74
+ taskId: name,
75
+ status: ledgerStatusFromPiSubagentRecord(record)
76
+ }, { resultConsumed: isConsumedTerminalPiSubagentRecord(record) });
77
+ } catch {}
78
+ continue;
79
+ }
80
+ if (record != null)
81
+ continue;
82
+ if (readPersistedPiSubagentTerminal(file))
83
+ continue;
84
+ try {
85
+ fs.unlinkSync(file);
86
+ removed.push(name);
87
+ } catch {}
88
+ }
89
+ return removed;
90
+ }
91
+ function readPiSubagentTerminalFile(file, requireConsumed) {
92
+ if (!file)
93
+ return null;
94
+ try {
95
+ const stat = fs.lstatSync(file);
96
+ if (stat.isSymbolicLink() || !stat.isFile())
97
+ return null;
98
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
99
+ if (!parsed || typeof parsed !== "object")
100
+ return null;
101
+ if (requireConsumed && parsed.resultConsumed !== true && parsed.consumed !== true)
102
+ return null;
103
+ if (typeof parsed.taskId !== "string" || parsed.taskId.length === 0)
104
+ return null;
105
+ if (parsed.status !== "completed" && parsed.status !== "failed" && parsed.status !== "cancelled" && parsed.status !== "timed_out") {
106
+ return null;
107
+ }
108
+ return { taskId: parsed.taskId, status: parsed.status };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ function writePiSubagentTerminalFile(file, input, resultConsumed) {
114
+ const existing = (() => {
115
+ try {
116
+ return fs.lstatSync(file);
117
+ } catch {
118
+ return null;
119
+ }
120
+ })();
121
+ if (existing?.isSymbolicLink())
122
+ throw new Error("subagent record file must not be a symlink");
123
+ const owner = readPiSubagentRecordOwner(file);
124
+ const payload = `${JSON.stringify({
125
+ taskId: input.taskId,
126
+ ...resultConsumed ? { resultConsumed: true } : {},
127
+ status: input.status,
128
+ ...owner ? { owner } : {}
129
+ })}
130
+ `;
131
+ if (existing?.isFile()) {
132
+ try {
133
+ if (fs.readFileSync(file, "utf8") === payload)
134
+ return;
135
+ } catch {}
136
+ }
137
+ fs.writeFileSync(file, payload, { mode: 384 });
138
+ fs.chmodSync(file, 384);
139
+ }
140
+ function readPiSubagentRecordOwner(file) {
141
+ try {
142
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
143
+ return typeof parsed?.owner === "string" && parsed.owner.trim() ? parsed.owner.trim() : undefined;
144
+ } catch {
145
+ return;
146
+ }
147
+ }
148
+
149
+ // src/runtime/pi-subagent-record-watchdog.ts
150
+ var PI_SUBAGENTS_MANAGER = Symbol.for("pi-subagents:manager");
151
+ var DEFAULT_SWEEP_INTERVAL_MS = 15000;
152
+ function piSubagentRecordWatchdog(pi) {
153
+ const stateDir = process.env.LARKIN_STATE_DIR;
154
+ const owner = process.env[PI_SUBAGENT_SESSION_OWNER_ENV];
155
+ if (!stateDir || !owner)
156
+ return;
157
+ const recordDir = dispatchedSubagentRecordDir(stateDir);
158
+ const intervalMs = sweepIntervalMs();
159
+ const tick = () => {
160
+ try {
161
+ const manager = globalThis[PI_SUBAGENTS_MANAGER];
162
+ if (typeof manager?.getRecord !== "function")
163
+ return;
164
+ sweepAbsentPiSubagentRecordFiles(recordDir, (taskId) => manager.getRecord(taskId), { owner });
165
+ } catch {}
166
+ };
167
+ const timer = setInterval(tick, intervalMs);
168
+ timer.unref?.();
169
+ const stop = () => {
170
+ clearInterval(timer);
171
+ tick();
172
+ };
173
+ pi.on("session_shutdown", stop);
174
+ }
175
+ function sweepIntervalMs() {
176
+ const raw = Number.parseInt(process.env.LARKIN_PI_SUBAGENT_RECORD_SWEEP_MS || "", 10);
177
+ if (Number.isInteger(raw) && raw > 0)
178
+ return raw;
179
+ return DEFAULT_SWEEP_INTERVAL_MS;
180
+ }
181
+ export {
182
+ piSubagentRecordWatchdog as default
183
+ };
@@ -0,0 +1,46 @@
1
+ import { dispatchedSubagentRecordDir, PI_SUBAGENT_SESSION_OWNER_ENV, sweepAbsentPiSubagentRecordFiles, } from "./pi-subagent-ledger.mjs";
2
+ const PI_SUBAGENTS_MANAGER = Symbol.for("pi-subagents:manager");
3
+ const DEFAULT_SWEEP_INTERVAL_MS = 15_000;
4
+ /**
5
+ * Pi-process watchdog: delete Larkin record sidecars once AgentManager no longer
6
+ * has the in-memory task. Persist any terminal AgentRecord — including an
7
+ * unconsumed completion Pi may evict after ten minutes — before treating a
8
+ * missing getRecord as a genuine orphan. A get_subagent_result consume also
9
+ * suppresses the canonical notification; bridge that consumed marker before
10
+ * cleanup can delete the sidecar. Leftover transcripts are intentionally
11
+ * ignored. Sidecars are owner-tagged so a staged Pi sharing LARKIN_STATE_DIR
12
+ * cannot sweep another session's records. This extension registers
13
+ * session_shutdown first so the final sweep still sees getRecord.
14
+ */
15
+ export default function piSubagentRecordWatchdog(pi) {
16
+ const stateDir = process.env.LARKIN_STATE_DIR;
17
+ const owner = process.env[PI_SUBAGENT_SESSION_OWNER_ENV];
18
+ if (!stateDir || !owner)
19
+ return;
20
+ const recordDir = dispatchedSubagentRecordDir(stateDir);
21
+ const intervalMs = sweepIntervalMs();
22
+ const tick = () => {
23
+ try {
24
+ const manager = globalThis[PI_SUBAGENTS_MANAGER];
25
+ if (typeof manager?.getRecord !== "function")
26
+ return;
27
+ sweepAbsentPiSubagentRecordFiles(recordDir, (taskId) => manager.getRecord(taskId), { owner });
28
+ }
29
+ catch {
30
+ // Best-effort: a transient EACCES/EMFILE must not crash the Pi timer.
31
+ }
32
+ };
33
+ const timer = setInterval(tick, intervalMs);
34
+ timer.unref?.();
35
+ const stop = () => {
36
+ clearInterval(timer);
37
+ tick();
38
+ };
39
+ pi.on("session_shutdown", stop);
40
+ }
41
+ function sweepIntervalMs() {
42
+ const raw = Number.parseInt(process.env.LARKIN_PI_SUBAGENT_RECORD_SWEEP_MS || "", 10);
43
+ if (Number.isInteger(raw) && raw > 0)
44
+ return raw;
45
+ return DEFAULT_SWEEP_INTERVAL_MS;
46
+ }
@@ -20,6 +20,20 @@ const TERMINAL_STATUS_TOKENS = new Set([
20
20
  "turn-limit",
21
21
  "turn_limit",
22
22
  ]);
23
+ export function ledgerStatusFromPiNotificationStatus(status) {
24
+ const normalized = status.trim().toLowerCase();
25
+ if (normalized.startsWith("error") || normalized === "error")
26
+ return "failed";
27
+ if (normalized.startsWith("stopped"))
28
+ return "cancelled";
29
+ if (normalized.startsWith("aborted")
30
+ || normalized.includes("turn limit")
31
+ || normalized.includes("turn-limit")
32
+ || normalized.includes("turn_limit")
33
+ || normalized.includes("steered"))
34
+ return "timed_out";
35
+ return "completed";
36
+ }
23
37
  export function isCanonicalTerminalPiSubagentStatus(status) {
24
38
  const normalized = status.trim().toLowerCase();
25
39
  if (!normalized)
@@ -11,7 +11,9 @@ import { BUNDLED_PI_VERSION, piAgentDirectory } from "./pi-provider-config.mjs";
11
11
  import { recordPiRuntimeArtifactProvenance } from "./pi-artifact-provenance.mjs";
12
12
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
13
13
  import { resolvePiSubagentExtensionArg } from "./pi-subagent-injection.mjs";
14
- import { extractCanonicalPiSubagentCompletionKeyFromMessages } from "./pi-subagents-notification.mjs";
14
+ import { extractCanonicalPiSubagentNotification, ledgerStatusFromPiNotificationStatus, } from "./pi-subagents-notification.mjs";
15
+ import { resolvePiSubagentRecordWatchdogExtensionArg } from "./pi-subagent-record-watchdog-injection.mjs";
16
+ import { effectivePiStateDir, extractBackgroundPiSubagentDispatch } from "./pi-subagent-ledger.mjs";
15
17
  import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
16
18
  import { classifyRuntimePrerequisite, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
17
19
  import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, } from "./provider-error-classifier.mjs";
@@ -455,6 +457,7 @@ class PiSession extends EventSession {
455
457
  observedCompletedEpochs = new Set();
456
458
  observedBackgroundCompletionKeys = new Set();
457
459
  pendingUnownedCompletionKeys = new Set();
460
+ pendingUnownedCompletionStatuses = new Map();
458
461
  observedAgentEndEpochs = new Set();
459
462
  firstOutputObserved = false;
460
463
  toolCallOpen = false;
@@ -557,6 +560,7 @@ class PiSession extends EventSession {
557
560
  this.observedCompletedEpochs.clear();
558
561
  this.observedBackgroundCompletionKeys.clear();
559
562
  this.pendingUnownedCompletionKeys.clear();
563
+ this.pendingUnownedCompletionStatuses.clear();
560
564
  this.observedAgentEndEpochs.clear();
561
565
  this.activeEpoch = null;
562
566
  this.settleArmedEpoch = null;
@@ -606,13 +610,34 @@ class PiSession extends EventSession {
606
610
  this.observedCompletedEpochs.add(this.activeEpoch);
607
611
  this.emitObservation("completed");
608
612
  }
609
- const completionNotificationKey = extractCanonicalPiSubagentCompletionKeyFromMessages(event.messages);
610
- if (completionNotificationKey
611
- && this.activeEpoch === null
612
- && !this.observedBackgroundCompletionKeys.has(completionNotificationKey)) {
613
- // agent_end can still have an active Pi session. Prompting before
614
- // unowned agent_settled is rejected as "Agent is already processing".
615
- this.pendingUnownedCompletionKeys.add(completionNotificationKey);
613
+ const completionNotification = extractCanonicalPiSubagentNotification(event.messages);
614
+ if (completionNotification && !this.observedBackgroundCompletionKeys.has(completionNotification.key)) {
615
+ const completionStatuses = Object.fromEntries(completionNotification.notifications.map((notification) => [
616
+ notification.taskId,
617
+ ledgerStatusFromPiNotificationStatus(notification.status),
618
+ ]));
619
+ const owningTurnFailed = event.willRetry === true
620
+ || this.finalAssistantStopReason === "error"
621
+ || this.finalAssistantStopReason === "aborted";
622
+ if (this.activeEpoch === null || owningTurnFailed) {
623
+ // Unowned agent_end still has an active Pi session, so prompting
624
+ // before settle is rejected as "Agent is already processing".
625
+ // A failed or retrying owned turn also did not process the
626
+ // notification; do not emit handledInTurn so RuntimeHost can still
627
+ // wake after input-error, retry, or restart.
628
+ this.pendingUnownedCompletionKeys.add(completionNotification.key);
629
+ this.pendingUnownedCompletionStatuses.set(completionNotification.key, completionStatuses);
630
+ }
631
+ else {
632
+ // Already visible in the owned turn. Persist as acknowledged; do not
633
+ // schedule another wake after the parent turn settles.
634
+ this.observedBackgroundCompletionKeys.add(completionNotification.key);
635
+ this.emitObservation("completed", {
636
+ completionKey: completionNotification.key,
637
+ completionStatuses,
638
+ handledInTurn: true,
639
+ });
640
+ }
616
641
  }
617
642
  }
618
643
  else if (event?.type === "agent_settled") {
@@ -683,6 +708,13 @@ class PiSession extends EventSession {
683
708
  this.toolCallOpen = false;
684
709
  this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "tool_result" });
685
710
  }
711
+ const dispatched = extractBackgroundPiSubagentDispatch(event);
712
+ if (dispatched) {
713
+ this.emitObservation("background_dispatched", {
714
+ taskId: dispatched.taskId,
715
+ ...(dispatched.outputFile ? { outputFile: dispatched.outputFile } : {}),
716
+ });
717
+ }
686
718
  }
687
719
  else if (event?.type === "message_update" && event.assistantMessageEvent?.delta) {
688
720
  if (!this.firstOutputObserved) {
@@ -698,9 +730,14 @@ class PiSession extends EventSession {
698
730
  if (this.observedBackgroundCompletionKeys.has(completionKey))
699
731
  continue;
700
732
  this.observedBackgroundCompletionKeys.add(completionKey);
701
- this.emitObservation("completed", { completionKey });
733
+ const completionStatuses = this.pendingUnownedCompletionStatuses.get(completionKey);
734
+ this.emitObservation("completed", {
735
+ completionKey,
736
+ ...(completionStatuses ? { completionStatuses } : {}),
737
+ });
702
738
  }
703
739
  this.pendingUnownedCompletionKeys.clear();
740
+ this.pendingUnownedCompletionStatuses.clear();
704
741
  }
705
742
  emitObservation(phase, fields = {}) {
706
743
  const inputId = this.oldestOwnedInput();
@@ -938,6 +975,11 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
938
975
  return [];
939
976
  const resolverInput = { distribution: "external", piCommand: input.piCommand, env: input.env };
940
977
  const args = [];
978
+ // Watchdog must load before the subagent extension so session_shutdown still
979
+ // sees AgentManager.getRecord and can bridge consumed or terminal state.
980
+ const recordWatchdog = (resolvers.recordWatchdog ?? resolvePiSubagentRecordWatchdogExtensionArg)(resolverInput);
981
+ if (recordWatchdog)
982
+ args.push("-e", recordWatchdog);
941
983
  const subagents = (resolvers.subagents ?? resolvePiSubagentExtensionArg)(resolverInput);
942
984
  if (subagents)
943
985
  args.push("-e", subagents);
@@ -947,7 +989,7 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
947
989
  return args;
948
990
  }
949
991
  async function createPiRpcBackend(input, dependencies, spawn, productionSpawn = true) {
950
- const stateRoot = input.stateDir ?? path.join(input.workspaceDir, ".larkin");
992
+ const stateRoot = effectivePiStateDir(input);
951
993
  const mergedEnv = { ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" };
952
994
  assertNoProjectPiCompactionOverride(input.workspaceDir);
953
995
  const ownedPiDirectory = mergedEnv.LARKIN_CONFIG_DIR