@tekmidian/pai 0.66.1 → 0.66.2
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/dist/{aibroker-client-B8c42Lh8.mjs → aibroker-client-CHEEvJZW.mjs} +2 -2
- package/dist/{aibroker-client-C5Fw7DNz.mjs → aibroker-client-Dfv7j1Od.mjs} +3 -3
- package/dist/{aibroker-client-C5Fw7DNz.mjs.map → aibroker-client-Dfv7j1Od.mjs.map} +1 -1
- package/dist/{auto-route-o0BOsXn7.mjs → auto-route-BnizyALK.mjs} +65 -4
- package/dist/auto-route-BnizyALK.mjs.map +1 -0
- package/dist/auto-route-D6fW1Q8z.mjs +3 -0
- package/dist/{chain-CZfKIEq9.mjs → chain-D5876UQX.mjs} +3 -4
- package/dist/{chain-CZfKIEq9.mjs.map → chain-D5876UQX.mjs.map} +1 -1
- package/dist/cli/index.mjs +18 -23
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +17 -22
- package/dist/{config-BbLFD7Uf.mjs → config-C-LiGlop.mjs} +2 -2
- package/dist/{config-BbLFD7Uf.mjs.map → config-C-LiGlop.mjs.map} +1 -1
- package/dist/{config-YinjgXEJ.mjs → config-DKIA7wgF.mjs} +1 -1
- package/dist/{context-handover-cache-pkHzmL3c.mjs → context-handover-cache-9PGvXRIw.mjs} +984 -10
- package/dist/context-handover-cache-9PGvXRIw.mjs.map +1 -0
- package/dist/daemon/index.mjs +17 -19
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BUgNvMUh.mjs +5029 -0
- package/dist/daemon-BUgNvMUh.mjs.map +1 -0
- package/dist/daemon-DL0eglMJ.mjs +18 -0
- package/dist/daemon-mcp/index.mjs +12 -9
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{embeddings-CcscYWwk.mjs → embeddings-CEBGrzwu.mjs} +1 -1
- package/dist/{embeddings-Bx3q0QOY.mjs → embeddings-DOLZnT1X.mjs} +1 -1
- package/dist/{embeddings-Bx3q0QOY.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
- package/dist/{env-JNEIrQWg.mjs → env-DiolswKQ.mjs} +1 -1
- package/dist/{env-JNEIrQWg.mjs.map → env-DiolswKQ.mjs.map} +1 -1
- package/dist/factory-DrBg24UT.mjs +8 -0
- package/dist/factory-Ypf8r0fK.mjs +2892 -0
- package/dist/factory-Ypf8r0fK.mjs.map +1 -0
- package/dist/{fallback-CIiq2Raw.mjs → fallback-D51R4shY.mjs} +23 -11
- package/dist/fallback-D51R4shY.mjs.map +1 -0
- package/dist/{chunker-BH4i-F2b.mjs → helpers-CZsi_49C.mjs} +221 -2
- package/dist/helpers-CZsi_49C.mjs.map +1 -0
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/index.mjs +6 -7
- package/dist/{ipc-client-D16Xw6Uo.mjs → ipc-client-BLYX51nG.mjs} +2 -2
- package/dist/{ipc-client-D16Xw6Uo.mjs.map → ipc-client-BLYX51nG.mjs.map} +1 -1
- package/dist/main-resolver-BYgP86C1.mjs +7 -0
- package/dist/{main-resolver-DPPwHNtn.mjs → main-resolver-kVkMJZUD.mjs} +6 -6
- package/dist/{main-resolver-DPPwHNtn.mjs.map → main-resolver-kVkMJZUD.mjs.map} +1 -1
- package/dist/{migrate-BD7D8EEh.mjs → migrate-Bq0esMOa.mjs} +2 -2
- package/dist/{migrate-BD7D8EEh.mjs.map → migrate-Bq0esMOa.mjs.map} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs → pai-marker-DXVpFsYz.mjs} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs.map → pai-marker-DXVpFsYz.mjs.map} +1 -1
- package/dist/{planner-Dm5Acyo1.mjs → planner-D5yt0EfA.mjs} +10 -7
- package/dist/{planner-Dm5Acyo1.mjs.map → planner-D5yt0EfA.mjs.map} +1 -1
- package/dist/postgres-BmLr0MUm.mjs +5 -0
- package/dist/{postgres-Ceqsa64C.mjs → postgres-D3xc2RB4.mjs} +364 -9
- package/dist/postgres-D3xc2RB4.mjs.map +1 -0
- package/dist/{program-JJ0TwSHI.mjs → program-DIXRtAFy.mjs} +77 -70
- package/dist/program-DIXRtAFy.mjs.map +1 -0
- package/dist/{query-feedback-BIaZTTFO.mjs → query-feedback-B7FYE4JR.mjs} +1 -1
- package/dist/{query-feedback-BIaZTTFO.mjs.map → query-feedback-B7FYE4JR.mjs.map} +1 -1
- package/dist/{reranker-DKv80KO5.mjs → reranker-3lnggwgq.mjs} +1 -1
- package/dist/{reranker-DKv80KO5.mjs.map → reranker-3lnggwgq.mjs.map} +1 -1
- package/dist/{reranker-CFiEzHQu.mjs → reranker-CZ2mP4cf.mjs} +1 -1
- package/dist/router-BK-hFeQ6.mjs +3 -0
- package/dist/{router-a5G7q8_3.mjs → router-BaTbc9VX.mjs} +2 -2
- package/dist/{router-a5G7q8_3.mjs.map → router-BaTbc9VX.mjs.map} +1 -1
- package/dist/{run-BCpbxqA5.mjs → run-CLYTOm0x.mjs} +599 -22
- package/dist/run-CLYTOm0x.mjs.map +1 -0
- package/dist/{run-env-DRcp7A8K.mjs → run-env-Bnmf6bRI.mjs} +2 -2
- package/dist/{run-env-DRcp7A8K.mjs.map → run-env-Bnmf6bRI.mjs.map} +1 -1
- package/dist/{runtime-paths-CHTg3ywb.mjs → runtime-paths-D3FKAm69.mjs} +1 -1
- package/dist/{runtime-paths-CHTg3ywb.mjs.map → runtime-paths-D3FKAm69.mjs.map} +1 -1
- package/dist/{search-BOSphCJ1.mjs → search-CNAGTiJP.mjs} +2 -2
- package/dist/{search-BOSphCJ1.mjs.map → search-CNAGTiJP.mjs.map} +1 -1
- package/dist/{sources-CijVso3n.mjs → sources-CM2g-CLT.mjs} +2 -2
- package/dist/{sources-CijVso3n.mjs.map → sources-CM2g-CLT.mjs.map} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs → stop-words-DtxaTWU_.mjs} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs.map → stop-words-DtxaTWU_.mjs.map} +1 -1
- package/dist/{utils-C9HsYDpO.mjs → utils-DddRwMsG.mjs} +2 -2
- package/dist/{utils-C9HsYDpO.mjs.map → utils-DddRwMsG.mjs.map} +1 -1
- package/dist/{zettelkasten-CtGHQWPU.mjs → zettelkasten-BhZMvmoK.mjs} +149 -7
- package/dist/zettelkasten-BhZMvmoK.mjs.map +1 -0
- package/dist/zettelkasten-BwV1pluL.mjs +5 -0
- package/docs/commands/worker.md +2 -0
- package/package.json +1 -1
- package/dist/async-C2Bm_Lal.mjs +0 -300
- package/dist/async-C2Bm_Lal.mjs.map +0 -1
- package/dist/auto-route-o0BOsXn7.mjs.map +0 -1
- package/dist/chunker-BH4i-F2b.mjs.map +0 -1
- package/dist/clusters-BCtD3fbe.mjs +0 -169
- package/dist/clusters-BCtD3fbe.mjs.map +0 -1
- package/dist/context-handover-cache-pkHzmL3c.mjs.map +0 -1
- package/dist/daemon-DXlaVCkw.mjs +0 -1741
- package/dist/daemon-DXlaVCkw.mjs.map +0 -1
- package/dist/daemon-DyORmxZu.mjs +0 -19
- package/dist/detector-BEPFsINR.mjs +0 -3
- package/dist/detector-BRTtAWrM.mjs +0 -65
- package/dist/detector-BRTtAWrM.mjs.map +0 -1
- package/dist/factory-CNpPQQ-5.mjs +0 -249
- package/dist/factory-CNpPQQ-5.mjs.map +0 -1
- package/dist/factory-DY7x73mE.mjs +0 -3
- package/dist/fallback-CIiq2Raw.mjs.map +0 -1
- package/dist/federation-db-BTyoufBh.mjs +0 -139
- package/dist/federation-db-BTyoufBh.mjs.map +0 -1
- package/dist/federation-db-HfIFc7FG.mjs +0 -3
- package/dist/helpers-BRCJg0G3.mjs +0 -223
- package/dist/helpers-BRCJg0G3.mjs.map +0 -1
- package/dist/indexer-backend-DFF2FrYx.mjs +0 -5
- package/dist/indexer-backend-isSLg6yE.mjs +0 -1
- package/dist/kg-entity-DCOcsVFD.mjs +0 -29
- package/dist/kg-entity-DCOcsVFD.mjs.map +0 -1
- package/dist/latent-ideas-wwAXquGe.mjs +0 -191
- package/dist/latent-ideas-wwAXquGe.mjs.map +0 -1
- package/dist/link-boost-CnI7UVMJ.mjs +0 -34
- package/dist/link-boost-CnI7UVMJ.mjs.map +0 -1
- package/dist/main-resolver-BKz_OuEh.mjs +0 -7
- package/dist/merge-2gqRPFu2.mjs +0 -3
- package/dist/merge-DgU9OgZy.mjs +0 -6
- package/dist/merge-DgU9OgZy.mjs.map +0 -1
- package/dist/module-paths-DdRzbkUI.mjs +0 -44
- package/dist/module-paths-DdRzbkUI.mjs.map +0 -1
- package/dist/neighborhood-2FsmzoxG.mjs +0 -114
- package/dist/neighborhood-2FsmzoxG.mjs.map +0 -1
- package/dist/note-context-De63k8na.mjs +0 -106
- package/dist/note-context-De63k8na.mjs.map +0 -1
- package/dist/postgres-Ceqsa64C.mjs.map +0 -1
- package/dist/program-JJ0TwSHI.mjs.map +0 -1
- package/dist/query-feedback-B_iigYj-.mjs +0 -3
- package/dist/registry-db-C7voqML9.mjs +0 -213
- package/dist/registry-db-C7voqML9.mjs.map +0 -1
- package/dist/registry-db-JHPhA8vF.mjs +0 -3
- package/dist/registry-postgres-nOfyBSIW.mjs +0 -795
- package/dist/registry-postgres-nOfyBSIW.mjs.map +0 -1
- package/dist/registry-sqlite-DrCW1aRK.mjs +0 -593
- package/dist/registry-sqlite-DrCW1aRK.mjs.map +0 -1
- package/dist/router-CXUGsv85.mjs +0 -3
- package/dist/run-BCpbxqA5.mjs.map +0 -1
- package/dist/search-Bf3Kub3F.mjs +0 -3
- package/dist/server-DgmAHyFK.mjs +0 -403
- package/dist/server-DgmAHyFK.mjs.map +0 -1
- package/dist/session-keepalive-DxjTHGcK.mjs +0 -582
- package/dist/session-keepalive-DxjTHGcK.mjs.map +0 -1
- package/dist/sqlite-CqsTy6xo.mjs +0 -936
- package/dist/sqlite-CqsTy6xo.mjs.map +0 -1
- package/dist/state-DW8zdweW.mjs +0 -3
- package/dist/state-HyjqTihC.mjs +0 -76
- package/dist/state-HyjqTihC.mjs.map +0 -1
- package/dist/themes-CTaOj3e1.mjs +0 -148
- package/dist/themes-CTaOj3e1.mjs.map +0 -1
- package/dist/tools-BcOKPBtg.mjs +0 -1489
- package/dist/tools-BcOKPBtg.mjs.map +0 -1
- package/dist/tools-C-_l0o8Z.mjs +0 -6
- package/dist/trace-CtV0RO6n.mjs +0 -137
- package/dist/trace-CtV0RO6n.mjs.map +0 -1
- package/dist/utils-BrX9FLeK.mjs +0 -3
- package/dist/vault-indexer-BG1sEVyM.mjs +0 -537
- package/dist/vault-indexer-BG1sEVyM.mjs.map +0 -1
- package/dist/wakeup-D8n33hYV.mjs +0 -335
- package/dist/wakeup-D8n33hYV.mjs.map +0 -1
- package/dist/work-queue-worker-4tLa5gpQ.mjs +0 -567
- package/dist/work-queue-worker-4tLa5gpQ.mjs.map +0 -1
- package/dist/work-queue-worker-rFZvTO3p.mjs +0 -11
- package/dist/zettelkasten-CtGHQWPU.mjs.map +0 -1
package/dist/daemon-DXlaVCkw.mjs
DELETED
|
@@ -1,1741 +0,0 @@
|
|
|
1
|
-
import { n as createRegistryBackend, o as getBackendOutage, r as createStorageBackend } from "./factory-CNpPQQ-5.mjs";
|
|
2
|
-
import { a as enqueue, c as loadQueue, o as getStats } from "./context-handover-cache-pkHzmL3c.mjs";
|
|
3
|
-
import { S as DEFAULT_NOTIFICATION_CONFIG, d as readMainConfigRaw, f as writeMainConfigRaw } from "./config-BbLFD7Uf.mjs";
|
|
4
|
-
import { D as readWorkersSection } from "./run-env-DRcp7A8K.mjs";
|
|
5
|
-
import { G as loadStatuses, H as isChatPane, P as appendLedger, R as alive, W as loadStatus, n as runWorker, q as nowStamp, x as itermForClaudeSession } from "./run-BCpbxqA5.mjs";
|
|
6
|
-
import { h as ledgerPath, m as eventsPath, v as workersLogDir } from "./server-DgmAHyFK.mjs";
|
|
7
|
-
import { A as vaultIndexInProgress, C as setShutdownRequested, D as shutdownRequested, E as setVaultIndexInProgress, O as startTime, S as setRegistryBackend, T as setStorageBackend, _ as setIndexSchedulerTimer, a as indexInProgress, b as setLastVaultIndexTime, c as lastIndexTime, d as registryBackend, f as setCacheKeepaliveTimer, g as setIndexInProgress, h as setEmbedSchedulerTimer, i as embedSchedulerTimer, k as storageBackend, l as lastVaultIndexTime, m as setEmbedInProgress, n as daemonConfig, o as indexSchedulerTimer, p as setDaemonConfig, r as embedInProgress, s as lastEmbedTime, t as cacheKeepaliveTimer, u as notificationConfig, v as setLastEmbedTime, w as setStartTime, x as setNotificationConfig, y as setLastIndexTime } from "./state-HyjqTihC.mjs";
|
|
8
|
-
import { n as runSessionKeepaliveTick } from "./session-keepalive-DxjTHGcK.mjs";
|
|
9
|
-
import { t as configureEmbeddingModel } from "./embeddings-Bx3q0QOY.mjs";
|
|
10
|
-
import { t as routeNotification } from "./router-a5G7q8_3.mjs";
|
|
11
|
-
import { C as toolProjectInfo, D as toolMemoryGet, O as toolMemoryOutline, S as toolProjectHealth, T as toolProjectTodo, a as toolMemoryWakeup, b as toolSessionRoute, i as toolMemoryTaxonomy, k as toolMemorySearch, n as toolMemoryFeedback, t as toolMemoryKgSearch, v as toolRegistrySearch, w as toolProjectList, x as toolProjectDetect, y as toolSessionList } from "./tools-BcOKPBtg.mjs";
|
|
12
|
-
import { t as detectTopicShift } from "./detector-BRTtAWrM.mjs";
|
|
13
|
-
import { i as stopWorker, n as notifyNewWork, r as startWorker } from "./work-queue-worker-4tLa5gpQ.mjs";
|
|
14
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { setPriority } from "node:os";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import { execFileSync } from "node:child_process";
|
|
18
|
-
import { connect, createServer } from "node:net";
|
|
19
|
-
|
|
20
|
-
//#region src/notifications/config.ts
|
|
21
|
-
/**
|
|
22
|
-
* config.ts — Notification config persistence helpers
|
|
23
|
-
*
|
|
24
|
-
* Reads and writes the `notifications` section of CONFIG_FILE (see
|
|
25
|
-
* src/daemon/config.ts's paiConfigFilePath).
|
|
26
|
-
* Deep-merges with defaults so partial configs work fine.
|
|
27
|
-
*
|
|
28
|
-
* This module is intentionally separate from the daemon's config loader
|
|
29
|
-
* so it can be used standalone (e.g. from CLI commands).
|
|
30
|
-
*/
|
|
31
|
-
function deepMerge(target, source) {
|
|
32
|
-
const result = { ...target };
|
|
33
|
-
for (const key of Object.keys(source)) {
|
|
34
|
-
const srcVal = source[key];
|
|
35
|
-
if (srcVal === void 0 || srcVal === null) continue;
|
|
36
|
-
const tgtVal = target[key];
|
|
37
|
-
if (typeof srcVal === "object" && !Array.isArray(srcVal) && typeof tgtVal === "object" && tgtVal !== null && !Array.isArray(tgtVal)) result[key] = deepMerge(tgtVal, srcVal);
|
|
38
|
-
else result[key] = srcVal;
|
|
39
|
-
}
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Load the notification config from the PAI config file.
|
|
44
|
-
* Returns defaults merged with any stored values.
|
|
45
|
-
*/
|
|
46
|
-
function loadNotificationConfig() {
|
|
47
|
-
let parsed;
|
|
48
|
-
try {
|
|
49
|
-
parsed = readMainConfigRaw();
|
|
50
|
-
} catch {
|
|
51
|
-
return { ...DEFAULT_NOTIFICATION_CONFIG };
|
|
52
|
-
}
|
|
53
|
-
const stored = parsed["notifications"];
|
|
54
|
-
if (!stored || typeof stored !== "object") return { ...DEFAULT_NOTIFICATION_CONFIG };
|
|
55
|
-
return deepMerge(DEFAULT_NOTIFICATION_CONFIG, stored);
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Persist the notification config by merging it into the existing
|
|
59
|
-
* CONFIG_FILE. Creates the file if it does not exist.
|
|
60
|
-
*/
|
|
61
|
-
function saveNotificationConfig(config) {
|
|
62
|
-
let full = {};
|
|
63
|
-
try {
|
|
64
|
-
full = readMainConfigRaw();
|
|
65
|
-
} catch {}
|
|
66
|
-
full["notifications"] = config;
|
|
67
|
-
writeMainConfigRaw(full);
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Apply a partial update to the current notification config and persist it.
|
|
71
|
-
* Returns the new merged config.
|
|
72
|
-
*/
|
|
73
|
-
function patchNotificationConfig(patch) {
|
|
74
|
-
const current = loadNotificationConfig();
|
|
75
|
-
if (patch.mode !== void 0) current.mode = patch.mode;
|
|
76
|
-
if (patch.channels) current.channels = deepMerge(current.channels, patch.channels);
|
|
77
|
-
if (patch.routing) current.routing = deepMerge(current.routing, patch.routing);
|
|
78
|
-
saveNotificationConfig(current);
|
|
79
|
-
return current;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
//#endregion
|
|
83
|
-
//#region src/workers/keepalive.ts
|
|
84
|
-
/**
|
|
85
|
-
* keepalive.ts — one cache-keepalive beat, shared by the daemon scheduler and
|
|
86
|
-
* the probe script.
|
|
87
|
-
*
|
|
88
|
-
* The provider (GLM anthropic-compat endpoint) caches the worker prefix
|
|
89
|
-
* implicitly — no cache_control blocks — so a trivial single-turn worker spawn
|
|
90
|
-
* through the very same runWorker path re-arms that cache for the next real
|
|
91
|
-
* worker: identical env, identical tool grants, identical WORKER_CONTRACT_PROMPT
|
|
92
|
-
* by construction, because it IS a worker spawn.
|
|
93
|
-
*
|
|
94
|
-
* Measured 2026-09-18 (docs/cache-keepalive.md): the implicit cache only
|
|
95
|
-
* serves spawns within seconds of the previous identical request and never
|
|
96
|
-
* survived a 60 s beat cadence in the armed proof — hence the knob defaults
|
|
97
|
-
* to off and arming it is the operator's explicit call.
|
|
98
|
-
*
|
|
99
|
-
* Deliberately NOT a hand-built claude invocation: duplicated prefix
|
|
100
|
-
* construction is how this repo has shipped real breakage before (probeResume
|
|
101
|
-
* x3, archiver x2). Everything flows through runWorker.
|
|
102
|
-
*/
|
|
103
|
-
/**
|
|
104
|
-
* The trivial prompt every cache beat (and probe) carries. Kept in one place
|
|
105
|
-
* so the heartbeat arms exactly the prefix a verifying probe measures.
|
|
106
|
-
*/
|
|
107
|
-
const HEARTBEAT_PROMPT = "Reply with exactly one word: pong.";
|
|
108
|
-
/** The worker class a beat runs as — the cheapest spawn the config defines. */
|
|
109
|
-
const KEEPALIVE_CLASS = "simple";
|
|
110
|
-
/** Worker label of every beat, so `pai worker ps` can tell them apart. */
|
|
111
|
-
const KEEPALIVE_LABEL = "cache-keepalive";
|
|
112
|
-
/** Cadence a keepalive runs at for this config; 0 keeps it off. */
|
|
113
|
-
function keepaliveSecs(workers) {
|
|
114
|
-
return workers.enabled ? workers.cacheKeepaliveSecs : 0;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* The LAST `type:"result"` event of a worker transcript, or null when the
|
|
118
|
-
* file is missing, unreadable, or carries no result line (a failed run can
|
|
119
|
-
* end without one).
|
|
120
|
-
*/
|
|
121
|
-
function parseLastResultEvent(path) {
|
|
122
|
-
let text;
|
|
123
|
-
try {
|
|
124
|
-
text = readFileSync(path, "utf8");
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
let found = null;
|
|
129
|
-
for (const line of text.split("\n")) {
|
|
130
|
-
if (!line.startsWith("{")) continue;
|
|
131
|
-
let e;
|
|
132
|
-
try {
|
|
133
|
-
e = JSON.parse(line);
|
|
134
|
-
} catch {
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
if (e.type === "result") found = e;
|
|
138
|
-
}
|
|
139
|
-
return found;
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Metrics of one run from its final result event. Single-turn runs make the
|
|
143
|
-
* cumulative result usage identical to first-turn usage, so a warm cache
|
|
144
|
-
* shows up directly as cache_read_input_tokens > 0.
|
|
145
|
-
*/
|
|
146
|
-
function extractBeatMetrics(m, ev) {
|
|
147
|
-
const u = ev?.usage;
|
|
148
|
-
return {
|
|
149
|
-
...m,
|
|
150
|
-
input_tokens: u?.input_tokens ?? null,
|
|
151
|
-
output_tokens: u?.output_tokens ?? null,
|
|
152
|
-
cache_read_input_tokens: u?.cache_read_input_tokens ?? null,
|
|
153
|
-
cache_creation_input_tokens: u?.cache_creation_input_tokens ?? null,
|
|
154
|
-
duration_api_ms: ev?.duration_api_ms ?? null,
|
|
155
|
-
duration_ms: ev?.duration_ms ?? null,
|
|
156
|
-
num_turns: ev?.num_turns ?? null
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
/** True while a beat's worker is still running — the overlap guard reads it. */
|
|
160
|
-
let beatInFlight = false;
|
|
161
|
-
/** `keepalive-<YYYYMMDD-HHMMSS>` — beats are minutes apart, seconds suffice. */
|
|
162
|
-
function beatId(d = /* @__PURE__ */ new Date()) {
|
|
163
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
164
|
-
return `keepalive-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* One heartbeat: a single-turn trivial worker through the exact worker spawn
|
|
168
|
-
* path, its result metrics parsed from the transcript, one WORKER-KEEPALIVE
|
|
169
|
-
* ledger line. Returns null when another beat is still running (overlap
|
|
170
|
-
* guard) — a skipped beat is free, a queue of them is not.
|
|
171
|
-
*
|
|
172
|
-
* The beat runs in the workers logDir: no session renders its status line
|
|
173
|
-
* there, so the spawn adopts no spawnerSession and no iTerm identity, and
|
|
174
|
-
* worker supervision has no owner to notify — heartbeats are infrastructure,
|
|
175
|
-
* not somebody's workers.
|
|
176
|
-
*/
|
|
177
|
-
async function runKeepaliveBeat(opts = {}) {
|
|
178
|
-
if (beatInFlight) return null;
|
|
179
|
-
beatInFlight = true;
|
|
180
|
-
try {
|
|
181
|
-
const { workers: config } = readWorkersSection(opts.configPath);
|
|
182
|
-
const logDir = workersLogDir(config);
|
|
183
|
-
const id = opts.id ?? beatId();
|
|
184
|
-
const rc = await runWorker({
|
|
185
|
-
claudeArgs: ["-p", HEARTBEAT_PROMPT],
|
|
186
|
-
className: KEEPALIVE_CLASS,
|
|
187
|
-
label: KEEPALIVE_LABEL,
|
|
188
|
-
noPane: true,
|
|
189
|
-
quiet: true,
|
|
190
|
-
worktreeFlag: false,
|
|
191
|
-
id,
|
|
192
|
-
cwd: logDir
|
|
193
|
-
});
|
|
194
|
-
const status = loadStatus(logDir, id);
|
|
195
|
-
const metrics = extractBeatMetrics({
|
|
196
|
-
id,
|
|
197
|
-
ok: rc === 0,
|
|
198
|
-
rc,
|
|
199
|
-
provider: status?.provider ?? null,
|
|
200
|
-
model: status?.model ?? null
|
|
201
|
-
}, parseLastResultEvent(eventsPath(logDir, id)));
|
|
202
|
-
appendLedger(ledgerPath(logDir), "WORKER-KEEPALIVE", {
|
|
203
|
-
id,
|
|
204
|
-
provider: metrics.provider,
|
|
205
|
-
model: metrics.model,
|
|
206
|
-
input_tokens: metrics.input_tokens,
|
|
207
|
-
cache_read_input_tokens: metrics.cache_read_input_tokens,
|
|
208
|
-
cache_creation_input_tokens: metrics.cache_creation_input_tokens,
|
|
209
|
-
duration_api_ms: metrics.duration_api_ms
|
|
210
|
-
});
|
|
211
|
-
return metrics;
|
|
212
|
-
} finally {
|
|
213
|
-
beatInFlight = false;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
//#endregion
|
|
218
|
-
//#region src/workers/supervision.ts
|
|
219
|
-
/**
|
|
220
|
-
* supervision.ts — daemon-side worker supervision, event push not polling.
|
|
221
|
-
*
|
|
222
|
-
* An orchestrating session must keep an eye on its workers without sleeping
|
|
223
|
-
* (the Worker skill forbids busy-waiting), the same way the AIBroker daemon
|
|
224
|
-
* keeps an eye on sessions. The PAI daemon already runs all day; this module
|
|
225
|
-
* gives it a tick that watches every worker's status file and pushes one-line
|
|
226
|
-
* events to the owning session when something happens:
|
|
227
|
-
*
|
|
228
|
-
* finished — the run ended clean (state done, rc 0)
|
|
229
|
-
* failed — the run ended badly (state failed/killed/lost, or rc != 0,
|
|
230
|
-
* or the runner pid vanished while state was still running)
|
|
231
|
-
* stalled — state running, runner alive, but no new turn for longer than
|
|
232
|
-
* the stall threshold (PAI_WORKER_STALL_MINUTES, default 10)
|
|
233
|
-
* thrashing — state running, runner alive, turning, but the last N tool
|
|
234
|
-
* executions all failed (PAI_WORKER_THRASH_FAILS, default 5) —
|
|
235
|
-
* a worker that keeps failing every command never goes quiet,
|
|
236
|
-
* so the stall detector alone cannot see it
|
|
237
|
-
* sleeping — state running, one Bash command sleeping N >= 60s with nothing
|
|
238
|
-
* to wait on — under the stall threshold for runs like
|
|
239
|
-
* `sleep 500` (8.3m), which would otherwise be invisible until
|
|
240
|
-
* they cross it. The bounded-wait pattern (background a build or
|
|
241
|
-
* test, then `sleep 60; ps` cycles) has live children and stays
|
|
242
|
-
* quiet; stalled and thrashing remain the backstop for a child
|
|
243
|
-
* that never finishes or dies silently
|
|
244
|
-
*
|
|
245
|
-
* The interactive chat pane (origin "chat") is never supervised: it is the
|
|
246
|
-
* operator's own session, not a task worker. Its status file flips to done
|
|
247
|
-
* when the session ends, and reporting that as "worker finished" to whoever
|
|
248
|
-
* next opens the same tab is noise, not an outcome.
|
|
249
|
-
*
|
|
250
|
-
* Delivery, in order of preference, never both counted as required:
|
|
251
|
-
* 1. AIBroker `send_to_session` into the orchestrator's terminal — the
|
|
252
|
-
* one-line Agentish-style notification, arriving as a user turn without
|
|
253
|
-
* the orchestrator asking for it. Needs the iTerm identity of the owner,
|
|
254
|
-
* which the status line bridges into the session map (see scope.ts).
|
|
255
|
-
* 2. An append-only event file, <logDir>/supervision/<owner>.events, one
|
|
256
|
-
* JSON line per event. The UserPromptSubmit hook
|
|
257
|
-
* (src/hooks/ts/user-prompt/worker-supervision.ts) surfaces undelivered
|
|
258
|
-
* lines into the session that owns them, so the events reach the model
|
|
259
|
-
* on its next turn even with AIBroker absent.
|
|
260
|
-
*
|
|
261
|
-
* Exactly-once: every event carries a deterministic id and the set of
|
|
262
|
-
* delivered ids per worker is persisted (<logDir>/supervision/state.json), so
|
|
263
|
-
* a daemon restart replays nothing. A first run with no state adopts every
|
|
264
|
-
* already-terminal worker as delivered — supervision reports transitions it
|
|
265
|
-
* observes, not history. Chain stages are separate workers with their own
|
|
266
|
-
* status files and are supervised individually.
|
|
267
|
-
*
|
|
268
|
-
* No model is ever called from here — this is daemon code, and it must stay
|
|
269
|
-
* that way: supervision that bills tokens to watch tokens is a bug.
|
|
270
|
-
*/
|
|
271
|
-
/** The kinds that fire mid-run and re-arm on turn progress (see eventId). */
|
|
272
|
-
const MID_RUN_KINDS = [
|
|
273
|
-
"stalled",
|
|
274
|
-
"thrashing",
|
|
275
|
-
"sleeping"
|
|
276
|
-
];
|
|
277
|
-
/**
|
|
278
|
-
* The owning session of a worker, or null when nobody claims it (and so
|
|
279
|
-
* nothing to notify): the spawner's claude session id first — that is what
|
|
280
|
-
* the hook knows a session by — else the AIBroker iTerm uuid.
|
|
281
|
-
*/
|
|
282
|
-
function ownershipKey(s) {
|
|
283
|
-
if (s.spawnerSession) return s.spawnerSession;
|
|
284
|
-
if (s.session?.id) return s.session.id;
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
const TERMINAL = [
|
|
288
|
-
"done",
|
|
289
|
-
"failed",
|
|
290
|
-
"killed",
|
|
291
|
-
"lost"
|
|
292
|
-
];
|
|
293
|
-
function isTerminal(s) {
|
|
294
|
-
return TERMINAL.includes(s.state);
|
|
295
|
-
}
|
|
296
|
-
/** Age of a "YYYY-MM-DD HH:MM:SS" stamp in ms, NaN-safe (unparsable → 0). */
|
|
297
|
-
function ageMs(ts, now) {
|
|
298
|
-
const t = Date.parse(ts.replace(" ", "T"));
|
|
299
|
-
if (Number.isNaN(t)) return 0;
|
|
300
|
-
return Math.max(0, now.getTime() - t);
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* The stall threshold in minutes from PAI_WORKER_STALL_MINUTES (default 10).
|
|
304
|
-
* Anything unparsible or non-positive falls back to the default — a broken
|
|
305
|
-
* value must not turn supervision off or make it fire every tick.
|
|
306
|
-
*/
|
|
307
|
-
const DEFAULT_STALL_MINUTES = 10;
|
|
308
|
-
function stallMinutesFromEnv(env = process.env) {
|
|
309
|
-
const raw = env.PAI_WORKER_STALL_MINUTES;
|
|
310
|
-
if (raw === void 0 || raw === "") return DEFAULT_STALL_MINUTES;
|
|
311
|
-
const n = Number(raw);
|
|
312
|
-
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STALL_MINUTES;
|
|
313
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* The consecutive-failure threshold from PAI_WORKER_THRASH_FAILS (default 5),
|
|
316
|
-
* with the same broken-value tolerance as stallMinutesFromEnv.
|
|
317
|
-
*/
|
|
318
|
-
const DEFAULT_THRASH_FAILS = 5;
|
|
319
|
-
function thrashFailsFromEnv(env = process.env) {
|
|
320
|
-
const raw = env.PAI_WORKER_THRASH_FAILS;
|
|
321
|
-
if (raw === void 0 || raw === "") return DEFAULT_THRASH_FAILS;
|
|
322
|
-
const n = Number(raw);
|
|
323
|
-
return Number.isFinite(n) && n > 0 ? n : DEFAULT_THRASH_FAILS;
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Event id: `<worker>#<kind>` for the once-per-run terminal kinds, and
|
|
327
|
-
* `<worker>#<kind>@<turns>` for the mid-run kinds (stalled, thrashing,
|
|
328
|
-
* sleeping) — the turn count re-arms the id, so a worker that resumes and
|
|
329
|
-
* hits the same condition again gets a second event while a restart cannot
|
|
330
|
-
* replay the first.
|
|
331
|
-
*/
|
|
332
|
-
function eventId(worker, kind, turns = 0) {
|
|
333
|
-
return MID_RUN_KINDS.includes(kind) ? `${worker}#${kind}@${turns}` : `${worker}#${kind}`;
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Live child pids of a runner via `pgrep -P` (its exit 1 means "no
|
|
337
|
-
* children"). Any failure — here or in an injected stub — is caught at the
|
|
338
|
-
* one call site in detectSupervisionEvents and reads as an empty list, so a
|
|
339
|
-
* broken check degrades to firing the sleeping event, never to silencing it.
|
|
340
|
-
*/
|
|
341
|
-
function pgrepChildren(pid) {
|
|
342
|
-
return execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }).split("\n").flatMap((l) => {
|
|
343
|
-
const n = Number(l.trim());
|
|
344
|
-
return Number.isInteger(n) && n > 0 ? [n] : [];
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
/** One event's one-liner — the exact line the orchestrator reads. */
|
|
348
|
-
function eventText(kind, s, rc, stalledMin) {
|
|
349
|
-
const tail = `- see pai worker replay ${s.id}`;
|
|
350
|
-
if (kind === "finished") return `worker ${s.id} ${s.label} finished rc=0 ${tail}`;
|
|
351
|
-
if (kind === "stalled") return `worker ${s.id} ${s.label} stalled ${stalledMin}m no turns ${tail}`;
|
|
352
|
-
if (kind === "thrashing") return `worker ${s.id} ${s.label} thrashing: ${s.consecFails} consecutive failed commands ${tail}`;
|
|
353
|
-
if (kind === "sleeping") return `worker ${s.id} ${s.label} sleeping ${s.sleepSec}s in one command ${tail}`;
|
|
354
|
-
const why = rc === null ? "runner gone" : `rc=${rc}`;
|
|
355
|
-
return `worker ${s.id} ${s.label} failed ${why} ${tail}`;
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* Detect conditions across a ledger snapshot. Pure: statuses in, events out;
|
|
359
|
-
* dedup against delivered ids is the caller's job (see filterUndelivered).
|
|
360
|
-
* Workers without an owning session are skipped — nobody to notify.
|
|
361
|
-
*/
|
|
362
|
-
function detectSupervisionEvents(statuses, opts) {
|
|
363
|
-
const isAlive = opts.isAlive ?? alive;
|
|
364
|
-
const deadGraceMs = opts.deadGraceMs ?? 15e3;
|
|
365
|
-
const out = [];
|
|
366
|
-
for (const s of statuses) {
|
|
367
|
-
const session = ownershipKey(s);
|
|
368
|
-
if (!session) continue;
|
|
369
|
-
if (isChatPane(s)) continue;
|
|
370
|
-
let kind = null;
|
|
371
|
-
let rc = null;
|
|
372
|
-
let stalledMin = null;
|
|
373
|
-
if (isTerminal(s)) if (s.state === "done" && (s.rc ?? 0) === 0) {
|
|
374
|
-
kind = "finished";
|
|
375
|
-
rc = 0;
|
|
376
|
-
} else {
|
|
377
|
-
kind = "failed";
|
|
378
|
-
rc = s.state === "lost" ? null : s.rc;
|
|
379
|
-
}
|
|
380
|
-
else if (!isAlive(s.pid) && ageMs(s.updated, opts.now) > deadGraceMs) {
|
|
381
|
-
kind = "failed";
|
|
382
|
-
rc = null;
|
|
383
|
-
} else if (ageMs(s.updated, opts.now) > opts.stallMs) {
|
|
384
|
-
kind = "stalled";
|
|
385
|
-
stalledMin = Math.floor(ageMs(s.updated, opts.now) / 6e4);
|
|
386
|
-
} else if ((s.consecFails ?? 0) >= (opts.thrashFails ?? DEFAULT_THRASH_FAILS)) kind = "thrashing";
|
|
387
|
-
else if (s.sleepSec != null) {
|
|
388
|
-
let hasChildren = false;
|
|
389
|
-
if (isAlive(s.pid)) try {
|
|
390
|
-
hasChildren = (opts.childPids ?? pgrepChildren)(s.pid).length > 0;
|
|
391
|
-
} catch {
|
|
392
|
-
hasChildren = false;
|
|
393
|
-
}
|
|
394
|
-
if (!hasChildren) kind = "sleeping";
|
|
395
|
-
}
|
|
396
|
-
if (!kind) continue;
|
|
397
|
-
out.push({
|
|
398
|
-
id: eventId(s.id, kind, s.turns),
|
|
399
|
-
ts: nowStamp(opts.now),
|
|
400
|
-
kind,
|
|
401
|
-
worker: s.id,
|
|
402
|
-
label: s.label,
|
|
403
|
-
session,
|
|
404
|
-
rc,
|
|
405
|
-
stalledMin,
|
|
406
|
-
...kind === "thrashing" ? { consecFails: s.consecFails ?? 0 } : {},
|
|
407
|
-
...kind === "sleeping" ? { sleepSec: s.sleepSec ?? 0 } : {},
|
|
408
|
-
text: eventText(kind, s, rc, stalledMin)
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
return out;
|
|
412
|
-
}
|
|
413
|
-
/** Cap on remembered workers: terminal and delivered is history, not state. */
|
|
414
|
-
const MAX_REMEMBERED_WORKERS = 500;
|
|
415
|
-
function supervisionDirPath(logDir) {
|
|
416
|
-
return join(logDir, "supervision");
|
|
417
|
-
}
|
|
418
|
-
function supervisionStatePath(logDir) {
|
|
419
|
-
return join(supervisionDirPath(logDir), "state.json");
|
|
420
|
-
}
|
|
421
|
-
function supervisionEventsPath(logDir, session) {
|
|
422
|
-
return join(supervisionDirPath(logDir), `${session}.events`);
|
|
423
|
-
}
|
|
424
|
-
/** Receipts of events already pushed into a terminal, one event id per line. */
|
|
425
|
-
function supervisionPushedPath(logDir, session) {
|
|
426
|
-
return join(supervisionDirPath(logDir), `${session}.pushed`);
|
|
427
|
-
}
|
|
428
|
-
function loadSupervisionState(path) {
|
|
429
|
-
if (!existsSync(path)) return {
|
|
430
|
-
state: { delivered: {} },
|
|
431
|
-
fresh: true
|
|
432
|
-
};
|
|
433
|
-
try {
|
|
434
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
435
|
-
if (parsed && typeof parsed === "object" && typeof parsed.delivered === "object") return {
|
|
436
|
-
state: parsed,
|
|
437
|
-
fresh: false
|
|
438
|
-
};
|
|
439
|
-
} catch {}
|
|
440
|
-
return {
|
|
441
|
-
state: { delivered: {} },
|
|
442
|
-
fresh: true
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
function saveSupervisionState(path, state) {
|
|
446
|
-
const dir = dirname(path);
|
|
447
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
448
|
-
const tmp = `${path}.tmp`;
|
|
449
|
-
writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
450
|
-
renameSync(tmp, path);
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Drop the delivered ids of workers the ledger no longer lists (absent means
|
|
454
|
-
* history: their status files are gone) and cap only that history — newest
|
|
455
|
-
* ids last, worker ids sort chronologically — so the persisted state stays
|
|
456
|
-
* bounded. A worker the ledger still lists must never lose its receipts:
|
|
457
|
-
* pruning one un-marks its terminal events as delivered, and every save
|
|
458
|
-
* re-arms them into an unbounded re-delivery loop (the 2026-09-25 incident:
|
|
459
|
-
* one old chain stage re-pushed 7,393 times, because the cap of 500 bit into
|
|
460
|
-
* a ledger of ~1,700 live statuses).
|
|
461
|
-
*/
|
|
462
|
-
function pruneState(state, statuses, maxAbsent = MAX_REMEMBERED_WORKERS) {
|
|
463
|
-
const present = new Set(statuses.map((s) => s.id));
|
|
464
|
-
const entries = Object.entries(state.delivered);
|
|
465
|
-
const byId = ([a], [b]) => a < b ? -1 : a > b ? 1 : 0;
|
|
466
|
-
const keptPresent = entries.filter(([id]) => present.has(id)).sort(byId);
|
|
467
|
-
const absent = entries.filter(([id]) => !present.has(id)).sort(byId);
|
|
468
|
-
const keptAbsent = absent.slice(Math.max(0, absent.length - maxAbsent));
|
|
469
|
-
return { delivered: Object.fromEntries([...keptAbsent, ...keptPresent]) };
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* First run: adopt every already-terminal worker as delivered. Supervision
|
|
473
|
-
* reports the transitions it observes from now on, never a replay of every
|
|
474
|
-
* worker that ever finished before it started.
|
|
475
|
-
*/
|
|
476
|
-
function baselineState(statuses) {
|
|
477
|
-
const delivered = {};
|
|
478
|
-
for (const s of statuses) {
|
|
479
|
-
if (!isTerminal(s) || !ownershipKey(s)) continue;
|
|
480
|
-
delivered[s.id] = [eventId(s.id, "finished"), eventId(s.id, "failed")];
|
|
481
|
-
}
|
|
482
|
-
return { delivered };
|
|
483
|
-
}
|
|
484
|
-
/** The events whose ids the state has not seen yet. */
|
|
485
|
-
function filterUndelivered(events, state) {
|
|
486
|
-
return events.filter((e) => !(state.delivered[e.worker] ?? []).includes(e.id));
|
|
487
|
-
}
|
|
488
|
-
function markDelivered(state, events) {
|
|
489
|
-
for (const e of events) {
|
|
490
|
-
const ids = state.delivered[e.worker] ?? [];
|
|
491
|
-
if (!ids.includes(e.id)) ids.push(e.id);
|
|
492
|
-
state.delivered[e.worker] = ids;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Default push: AIBroker `send_to_session` into the orchestrator's terminal,
|
|
497
|
-
* using the iTerm uuid the worker recorded (`session.id`) or the one the
|
|
498
|
-
* session map bridges for the spawner's claude session. A timeout still
|
|
499
|
-
* delivered (see aibroker-client) counts as delivered; every failure is
|
|
500
|
-
* reported as not-delivered so only the event file carries the event.
|
|
501
|
-
*/
|
|
502
|
-
const aibrokerPush = async (logDir, target, ev) => {
|
|
503
|
-
const iterm = target.iterm ?? (target.claudeSession ? itermForClaudeSession(logDir, target.claudeSession) : null);
|
|
504
|
-
if (!iterm) return false;
|
|
505
|
-
try {
|
|
506
|
-
const { sendToSession } = await import("./aibroker-client-B8c42Lh8.mjs");
|
|
507
|
-
const r = await sendToSession(iterm, ev.text);
|
|
508
|
-
return r.ok || r.timedOut === true;
|
|
509
|
-
} catch {
|
|
510
|
-
return false;
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
/**
|
|
514
|
-
* Whether an event should be relayed into the owner's terminal. The mid-run
|
|
515
|
-
* kinds (stalled, thrashing, sleeping) ALWAYS relay: a json/stream-json
|
|
516
|
-
* caller knows the run's result at exit but has no other way to hear
|
|
517
|
-
* mid-run trouble. Only the terminal kinds (finished/failed) skip the relay
|
|
518
|
-
* for those callers — they read the run's result itself, and the harness
|
|
519
|
-
* already notifies them on process exit, so the one-liner would cost a full
|
|
520
|
-
* extra turn for information they already have. Interactive/pane launches
|
|
521
|
-
* and the default text format are never skipped.
|
|
522
|
-
*/
|
|
523
|
-
function shouldRelay(kind, outputFormat) {
|
|
524
|
-
if (MID_RUN_KINDS.includes(kind)) return true;
|
|
525
|
-
return outputFormat !== "json" && outputFormat !== "stream-json";
|
|
526
|
-
}
|
|
527
|
-
function appendEventFile(logDir, ev) {
|
|
528
|
-
const path = supervisionEventsPath(logDir, ev.session);
|
|
529
|
-
const dir = supervisionDirPath(logDir);
|
|
530
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
531
|
-
appendFileSync(path, JSON.stringify(ev) + "\n", "utf8");
|
|
532
|
-
}
|
|
533
|
-
/** Record that an event reached its owner's terminal — the hook then skips it. */
|
|
534
|
-
function appendPushReceipt(logDir, ev) {
|
|
535
|
-
try {
|
|
536
|
-
appendFileSync(supervisionPushedPath(logDir, ev.session), `${ev.id}\n`, "utf8");
|
|
537
|
-
} catch {}
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* One supervision pass over the ledger: detect, append each new event to its
|
|
541
|
-
* owner's event file, push it best effort, persist the delivered ids. The
|
|
542
|
-
* event file is the durable record — an event is marked delivered only after
|
|
543
|
-
* its line landed there, so a failed tick retries, a repeated one replays
|
|
544
|
-
* nothing.
|
|
545
|
-
*/
|
|
546
|
-
async function runSupervisionTick(logDir, opts = {}) {
|
|
547
|
-
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
548
|
-
const statuses = opts.statuses ?? loadStatuses(logDir);
|
|
549
|
-
const statePath = supervisionStatePath(logDir);
|
|
550
|
-
const loaded = loadSupervisionState(statePath);
|
|
551
|
-
let state = loaded.state;
|
|
552
|
-
if (loaded.fresh) state = baselineState(statuses);
|
|
553
|
-
const pending = filterUndelivered(detectSupervisionEvents(statuses, {
|
|
554
|
-
stallMs: opts.stallMs ?? DEFAULT_STALL_MINUTES * 6e4,
|
|
555
|
-
thrashFails: opts.thrashFails ?? thrashFailsFromEnv(),
|
|
556
|
-
now,
|
|
557
|
-
...opts.isAlive ? { isAlive: opts.isAlive } : {}
|
|
558
|
-
}), state);
|
|
559
|
-
const written = [];
|
|
560
|
-
for (const ev of pending) {
|
|
561
|
-
appendEventFile(logDir, ev);
|
|
562
|
-
written.push(ev);
|
|
563
|
-
}
|
|
564
|
-
const push = opts.push ?? aibrokerPush;
|
|
565
|
-
const pushed = [];
|
|
566
|
-
for (const ev of written) {
|
|
567
|
-
const owner = statuses.find((s) => s.id === ev.worker);
|
|
568
|
-
if (!shouldRelay(ev.kind, owner?.outputFormat)) continue;
|
|
569
|
-
const target = {
|
|
570
|
-
claudeSession: owner?.spawnerSession ?? null,
|
|
571
|
-
iterm: owner?.session?.id ?? null
|
|
572
|
-
};
|
|
573
|
-
let ok = false;
|
|
574
|
-
try {
|
|
575
|
-
ok = await push(logDir, target, ev);
|
|
576
|
-
} catch {
|
|
577
|
-
ok = false;
|
|
578
|
-
}
|
|
579
|
-
if (ok) {
|
|
580
|
-
pushed.push(ev);
|
|
581
|
-
appendPushReceipt(logDir, ev);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
markDelivered(state, written);
|
|
585
|
-
saveSupervisionState(statePath, pruneState(state, statuses));
|
|
586
|
-
return {
|
|
587
|
-
events: written,
|
|
588
|
-
pushed
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
//#endregion
|
|
593
|
-
//#region src/daemon/daemon/scheduler.ts
|
|
594
|
-
/**
|
|
595
|
-
* Index, embed, vault index, and registry scan schedulers for the PAI daemon.
|
|
596
|
-
* Exports run* functions (also called on-demand by the IPC handler)
|
|
597
|
-
* and the start* functions invoked once at daemon startup.
|
|
598
|
-
*/
|
|
599
|
-
/** Minimum interval between vault index runs (30 minutes). */
|
|
600
|
-
const VAULT_INDEX_MIN_INTERVAL_MS = 1800 * 1e3;
|
|
601
|
-
/**
|
|
602
|
-
* Run a full index pass. Guards against overlapping runs with indexInProgress.
|
|
603
|
-
* Called both by the scheduler and by the index_now IPC method.
|
|
604
|
-
*/
|
|
605
|
-
async function runIndex() {
|
|
606
|
-
if (indexInProgress) {
|
|
607
|
-
process.stderr.write("[pai-daemon] Index already in progress, skipping.\n");
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
if (embedInProgress) {
|
|
611
|
-
process.stderr.write("[pai-daemon] Embed in progress, deferring index run.\n");
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
setIndexInProgress(true);
|
|
615
|
-
const t0 = Date.now();
|
|
616
|
-
try {
|
|
617
|
-
process.stderr.write("[pai-daemon] Starting scheduled index run...\n");
|
|
618
|
-
const { projects, result } = await storageBackend.indexAll(registryBackend);
|
|
619
|
-
const elapsed = Date.now() - t0;
|
|
620
|
-
setLastIndexTime(Date.now());
|
|
621
|
-
process.stderr.write(`[pai-daemon] Index complete: ${projects} projects, ${result.filesProcessed} files, ${result.chunksCreated} chunks (${elapsed}ms)\n`);
|
|
622
|
-
} catch (e) {
|
|
623
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
624
|
-
process.stderr.write(`[pai-daemon] Index error: ${msg}\n`);
|
|
625
|
-
} finally {
|
|
626
|
-
setIndexInProgress(false);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Run a vault index pass. Guards against overlapping runs with vaultIndexInProgress.
|
|
631
|
-
* Skips if no vaultPath is configured, or if project index/embed is in progress.
|
|
632
|
-
*/
|
|
633
|
-
async function runVaultIndex() {
|
|
634
|
-
if (!daemonConfig.vaultPath) return;
|
|
635
|
-
if (vaultIndexInProgress) {
|
|
636
|
-
process.stderr.write("[pai-daemon] Vault index already in progress, skipping.\n");
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
if (indexInProgress || embedInProgress) {
|
|
640
|
-
process.stderr.write("[pai-daemon] Index/embed in progress, deferring vault index.\n");
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
const { lastVaultIndexTime } = await import("./state-DW8zdweW.mjs");
|
|
644
|
-
if (lastVaultIndexTime > 0 && Date.now() - lastVaultIndexTime < VAULT_INDEX_MIN_INTERVAL_MS) return;
|
|
645
|
-
let vaultProjectId = daemonConfig.vaultProjectId;
|
|
646
|
-
if (!vaultProjectId) {
|
|
647
|
-
const row = await registryBackend.getProjectByRootPath(daemonConfig.vaultPath);
|
|
648
|
-
vaultProjectId = row?.id ?? 999;
|
|
649
|
-
if (!row) process.stderr.write("[pai-daemon] Vault not in project registry — using synthetic project ID 999.\n");
|
|
650
|
-
}
|
|
651
|
-
setVaultIndexInProgress(true);
|
|
652
|
-
const t0 = Date.now();
|
|
653
|
-
process.stderr.write("[pai-daemon] Starting vault index run...\n");
|
|
654
|
-
try {
|
|
655
|
-
const { indexVault } = await import("./vault-indexer-BG1sEVyM.mjs");
|
|
656
|
-
const r = await indexVault(storageBackend, vaultProjectId, daemonConfig.vaultPath);
|
|
657
|
-
const elapsed = Date.now() - t0;
|
|
658
|
-
setLastVaultIndexTime(Date.now());
|
|
659
|
-
process.stderr.write(`[pai-daemon] Vault index complete: ${r.filesIndexed} files, ${r.linksExtracted} links, ${r.deadLinksFound} dead, ${r.orphansFound} orphans (${elapsed}ms)\n`);
|
|
660
|
-
} catch (e) {
|
|
661
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
662
|
-
process.stderr.write(`[pai-daemon] Vault index error: ${msg}\n`);
|
|
663
|
-
} finally {
|
|
664
|
-
setVaultIndexInProgress(false);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Start the periodic index scheduler. Runs an initial pass 2 seconds after startup.
|
|
669
|
-
*/
|
|
670
|
-
function startIndexScheduler() {
|
|
671
|
-
const intervalMs = daemonConfig.indexIntervalSecs * 1e3;
|
|
672
|
-
process.stderr.write(`[pai-daemon] Index scheduler: every ${daemonConfig.indexIntervalSecs}s\n`);
|
|
673
|
-
const cycle = (label, withEmbed) => runIndex().then(() => runVaultIndex()).then(() => withEmbed ? runEmbed() : void 0).catch((e) => {
|
|
674
|
-
process.stderr.write(`[pai-daemon] ${label} index error: ${e}\n`);
|
|
675
|
-
});
|
|
676
|
-
if (daemonConfig.maintenanceHour === void 0) setTimeout(() => void cycle("Startup", daemonConfig.embedOnStartup), 2e3);
|
|
677
|
-
else process.stderr.write("[pai-daemon] Startup index pass skipped (maintenanceHour is set).\n");
|
|
678
|
-
const firstDelayMs = msUntilNextAnchor(daemonConfig.maintenanceHour, intervalMs);
|
|
679
|
-
if (daemonConfig.maintenanceHour !== void 0) process.stderr.write(`[pai-daemon] Index scheduler anchored to ${String(daemonConfig.maintenanceHour).padStart(2, "0")}:00 local (next pass in ${Math.round(firstDelayMs / 6e4)} min)\n`);
|
|
680
|
-
let timer;
|
|
681
|
-
const startTimer = () => {
|
|
682
|
-
timer = setInterval(() => void cycle("Scheduled", true), intervalMs);
|
|
683
|
-
if (timer.unref) timer.unref();
|
|
684
|
-
setIndexSchedulerTimer(timer);
|
|
685
|
-
};
|
|
686
|
-
const first = setTimeout(() => {
|
|
687
|
-
cycle("Scheduled", true);
|
|
688
|
-
startTimer();
|
|
689
|
-
}, firstDelayMs);
|
|
690
|
-
if (first.unref) first.unref();
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Milliseconds until the next occurrence of `hour`:00 local time.
|
|
694
|
-
* Falls back to the plain interval when no anchor hour is configured.
|
|
695
|
-
*/
|
|
696
|
-
function msUntilNextAnchor(hour, intervalMs) {
|
|
697
|
-
if (hour === void 0 || !Number.isInteger(hour) || hour < 0 || hour > 23) return intervalMs;
|
|
698
|
-
const now = /* @__PURE__ */ new Date();
|
|
699
|
-
const next = new Date(now);
|
|
700
|
-
next.setHours(hour, 0, 0, 0);
|
|
701
|
-
if (next <= now) next.setDate(next.getDate() + 1);
|
|
702
|
-
return next.getTime() - now.getTime();
|
|
703
|
-
}
|
|
704
|
-
/**
|
|
705
|
-
* Run an embedding pass for all unembedded chunks (Postgres backend only).
|
|
706
|
-
*/
|
|
707
|
-
async function runEmbed() {
|
|
708
|
-
if (embedInProgress) {
|
|
709
|
-
process.stderr.write("[pai-daemon] Embed already in progress, skipping.\n");
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
if (indexInProgress) {
|
|
713
|
-
process.stderr.write("[pai-daemon] Index in progress, deferring embed pass.\n");
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
if (!storageBackend.supportsPostgresFeatures) return;
|
|
717
|
-
setEmbedInProgress(true);
|
|
718
|
-
const t0 = Date.now();
|
|
719
|
-
try {
|
|
720
|
-
process.stderr.write("[pai-daemon] Starting scheduled embed pass...\n");
|
|
721
|
-
const projectNames = /* @__PURE__ */ new Map();
|
|
722
|
-
try {
|
|
723
|
-
const rows = await registryBackend.listProjects({ status: "active" });
|
|
724
|
-
for (const r of rows) projectNames.set(r.id, r.slug);
|
|
725
|
-
} catch {}
|
|
726
|
-
const { embedChunksWithBackend } = await import("./indexer-backend-DFF2FrYx.mjs");
|
|
727
|
-
const count = await embedChunksWithBackend(storageBackend, () => shutdownRequested, projectNames, { maxMillis: 24e4 });
|
|
728
|
-
const elapsed = Date.now() - t0;
|
|
729
|
-
setLastEmbedTime(Date.now());
|
|
730
|
-
process.stderr.write(`[pai-daemon] Embed pass complete: ${count} chunks embedded (${elapsed}ms)\n`);
|
|
731
|
-
} catch (e) {
|
|
732
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
733
|
-
process.stderr.write(`[pai-daemon] Embed error: ${msg}\n`);
|
|
734
|
-
} finally {
|
|
735
|
-
setEmbedInProgress(false);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
/**
|
|
739
|
-
* Start the periodic embed scheduler. Initial run is 60 seconds after startup.
|
|
740
|
-
*/
|
|
741
|
-
function startEmbedScheduler() {
|
|
742
|
-
const intervalMs = daemonConfig.embedIntervalSecs * 1e3;
|
|
743
|
-
process.stderr.write(`[pai-daemon] Embed scheduler: every ${daemonConfig.embedIntervalSecs}s\n`);
|
|
744
|
-
if (daemonConfig.embedOnStartup) setTimeout(() => {
|
|
745
|
-
runEmbed().catch((e) => {
|
|
746
|
-
process.stderr.write(`[pai-daemon] Startup embed error: ${e}\n`);
|
|
747
|
-
});
|
|
748
|
-
}, 6e4);
|
|
749
|
-
else process.stderr.write("[pai-daemon] Startup embed pass skipped (embedOnStartup=false).\n");
|
|
750
|
-
if (daemonConfig.maintenanceHour !== void 0) {
|
|
751
|
-
process.stderr.write("[pai-daemon] Standalone embed timer not armed (maintenanceHour is set; embed runs at the end of each anchored index cycle).\n");
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
const timer = setInterval(() => {
|
|
755
|
-
runEmbed().catch((e) => {
|
|
756
|
-
process.stderr.write(`[pai-daemon] Scheduled embed error: ${e}\n`);
|
|
757
|
-
});
|
|
758
|
-
}, intervalMs);
|
|
759
|
-
if (timer.unref) timer.unref();
|
|
760
|
-
setEmbedSchedulerTimer(timer);
|
|
761
|
-
}
|
|
762
|
-
const REGISTRY_SCAN_INTERVAL_MS = 1800 * 1e3;
|
|
763
|
-
const REGISTRY_SCAN_STARTUP_DELAY_MS = 1e4;
|
|
764
|
-
/**
|
|
765
|
-
* Start the periodic registry scan scheduler.
|
|
766
|
-
* First scan runs 10 seconds after startup (enough time for IPC to stabilise).
|
|
767
|
-
* Subsequent scans run every 30 minutes.
|
|
768
|
-
* Uses the work-queue so the scan runs on the worker thread without blocking IPC.
|
|
769
|
-
*/
|
|
770
|
-
function startRegistryScanScheduler() {
|
|
771
|
-
process.stderr.write("[pai-daemon] Registry scan scheduler: every 30min (first in 10s)\n");
|
|
772
|
-
setTimeout(() => {
|
|
773
|
-
import("./work-queue-worker-rFZvTO3p.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
|
|
774
|
-
process.stderr.write(`[pai-daemon] Startup registry scan error: ${e}\n`);
|
|
775
|
-
});
|
|
776
|
-
}, REGISTRY_SCAN_STARTUP_DELAY_MS);
|
|
777
|
-
const timer = setInterval(() => {
|
|
778
|
-
import("./work-queue-worker-rFZvTO3p.mjs").then(({ enqueueRegistryScan }) => enqueueRegistryScan()).catch((e) => {
|
|
779
|
-
process.stderr.write(`[pai-daemon] Scheduled registry scan error: ${e}\n`);
|
|
780
|
-
});
|
|
781
|
-
}, REGISTRY_SCAN_INTERVAL_MS);
|
|
782
|
-
if (timer.unref) timer.unref();
|
|
783
|
-
}
|
|
784
|
-
/** How often the daemon looks at the worker ledger for supervision events. */
|
|
785
|
-
const SUPERVISION_TICK_MS = 3e4;
|
|
786
|
-
/** First supervision pass waits for the first statuses to exist at all. */
|
|
787
|
-
const SUPERVISION_STARTUP_DELAY_MS = 15e3;
|
|
788
|
-
/**
|
|
789
|
-
* Start the worker supervisor: one tick every 30 s over the workers ledger
|
|
790
|
-
* (src/workers/supervision.ts) that pushes finished/failed/stalled events to
|
|
791
|
-
* the owning session — the daemon-side replacement for orchestrator polling.
|
|
792
|
-
* Daemon code only: no model call has any business being here, and none is
|
|
793
|
-
* made. Disabled workers disable the supervisor with it.
|
|
794
|
-
*/
|
|
795
|
-
function startWorkerSupervisor() {
|
|
796
|
-
let enabled = false;
|
|
797
|
-
try {
|
|
798
|
-
enabled = readWorkersSection().workers.enabled;
|
|
799
|
-
} catch (e) {
|
|
800
|
-
process.stderr.write(`[pai-daemon] Worker supervisor: not started (${e instanceof Error ? e.message : String(e)})\n`);
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
if (!enabled) {
|
|
804
|
-
process.stderr.write("[pai-daemon] Worker supervisor: disabled (workers are off)\n");
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
const tick = () => {
|
|
808
|
-
runSupervisionTick(workersLogDir(readWorkersSection().workers), {
|
|
809
|
-
stallMs: stallMinutesFromEnv() * 6e4,
|
|
810
|
-
thrashFails: thrashFailsFromEnv()
|
|
811
|
-
}).then((r) => {
|
|
812
|
-
for (const ev of r.events) process.stderr.write(`[pai-daemon] Supervision: ${ev.text}\n`);
|
|
813
|
-
}).catch((e) => {
|
|
814
|
-
process.stderr.write(`[pai-daemon] Supervision tick error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
815
|
-
});
|
|
816
|
-
};
|
|
817
|
-
const first = setTimeout(tick, SUPERVISION_STARTUP_DELAY_MS);
|
|
818
|
-
if (first.unref) first.unref();
|
|
819
|
-
const timer = setInterval(tick, SUPERVISION_TICK_MS);
|
|
820
|
-
if (timer.unref) timer.unref();
|
|
821
|
-
process.stderr.write(`[pai-daemon] Worker supervisor: every ${Math.round(SUPERVISION_TICK_MS / 1e3)}s\n`);
|
|
822
|
-
}
|
|
823
|
-
/** First beat waits a while after boot; the daemon start is itself a warm request. */
|
|
824
|
-
const KEEPALIVE_STARTUP_DELAY_MS = 6e4;
|
|
825
|
-
/**
|
|
826
|
-
* Start the cache keepalive: every workers.cacheKeepaliveSecs one trivial
|
|
827
|
-
* single-turn worker (src/workers/keepalive.ts) re-arms the provider's
|
|
828
|
-
* implicit prompt cache so real worker spawns start warm. Needs no storage
|
|
829
|
-
* backend — it starts with the IPC server, not after the federation connect.
|
|
830
|
-
* 0 (or workers off) keeps it off. A failed beat is logged and swallowed.
|
|
831
|
-
*/
|
|
832
|
-
function startCacheKeepalive(opts = {}) {
|
|
833
|
-
let workers;
|
|
834
|
-
try {
|
|
835
|
-
workers = readWorkersSection(opts.configPath).workers;
|
|
836
|
-
} catch (e) {
|
|
837
|
-
process.stderr.write(`[pai-daemon] Cache keepalive: not started (${e instanceof Error ? e.message : String(e)})\n`);
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
840
|
-
if (!workers.enabled) {
|
|
841
|
-
process.stderr.write("[pai-daemon] Cache keepalive: disabled (workers are off)\n");
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
const secs = keepaliveSecs(workers);
|
|
845
|
-
if (!secs) {
|
|
846
|
-
process.stderr.write("[pai-daemon] Cache keepalive: disabled (cacheKeepaliveSecs is 0)\n");
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
let notedNoPostgres = false;
|
|
850
|
-
const supportsObservations = () => {
|
|
851
|
-
if (storageBackend?.supportsPostgresFeatures) return true;
|
|
852
|
-
if (!notedNoPostgres) {
|
|
853
|
-
notedNoPostgres = true;
|
|
854
|
-
process.stderr.write("[pai-daemon] Cache keepalive: observation rows need Postgres — ledger line only\n");
|
|
855
|
-
}
|
|
856
|
-
return false;
|
|
857
|
-
};
|
|
858
|
-
const beat = async () => {
|
|
859
|
-
const m = await (opts.beat ?? runKeepaliveBeat)();
|
|
860
|
-
if (!m) return;
|
|
861
|
-
if (!supportsObservations()) return;
|
|
862
|
-
try {
|
|
863
|
-
await storageBackend.storeObservationWithProject(registryBackend, {
|
|
864
|
-
session_id: m.id,
|
|
865
|
-
type: "change",
|
|
866
|
-
title: `cache keepalive ${m.ok ? "ok" : "failed"} (cache_read=${m.cache_read_input_tokens}, api_ms=${m.duration_api_ms})`,
|
|
867
|
-
tool_name: "cache-keepalive",
|
|
868
|
-
narrative: `beat ${m.id}: input=${m.input_tokens} cache_read=${m.cache_read_input_tokens} cache_creation=${m.cache_creation_input_tokens} duration_api_ms=${m.duration_api_ms}`,
|
|
869
|
-
tool_input_summary: void 0,
|
|
870
|
-
files_read: [],
|
|
871
|
-
files_modified: [],
|
|
872
|
-
concepts: []
|
|
873
|
-
});
|
|
874
|
-
} catch (e) {
|
|
875
|
-
process.stderr.write(`[pai-daemon] Cache keepalive observation error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
876
|
-
}
|
|
877
|
-
};
|
|
878
|
-
const first = setTimeout(() => {
|
|
879
|
-
beat().catch((e) => {
|
|
880
|
-
process.stderr.write(`[pai-daemon] Cache keepalive beat error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
881
|
-
});
|
|
882
|
-
}, KEEPALIVE_STARTUP_DELAY_MS);
|
|
883
|
-
if (first.unref) first.unref();
|
|
884
|
-
const timer = setInterval(() => {
|
|
885
|
-
beat().catch((e) => {
|
|
886
|
-
process.stderr.write(`[pai-daemon] Cache keepalive beat error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
887
|
-
});
|
|
888
|
-
}, secs * 1e3);
|
|
889
|
-
if (timer.unref) timer.unref();
|
|
890
|
-
setCacheKeepaliveTimer(timer);
|
|
891
|
-
process.stderr.write(`[pai-daemon] Cache keepalive: every ${secs}s\n`);
|
|
892
|
-
}
|
|
893
|
-
/** Tick cadence: coarse enough to be cheap, fine enough that a session
|
|
894
|
-
* crossing idleMinutes gets beaten within a minute of becoming eligible. */
|
|
895
|
-
const SESSION_KEEPALIVE_TICK_MS = 6e4;
|
|
896
|
-
/**
|
|
897
|
-
* Start the interactive-session cache keepalive: every 60s, beat every live
|
|
898
|
-
* session that has gone idle past `sessions.cacheKeepalive.idleMinutes`
|
|
899
|
-
* (src/daemon/session-keepalive.ts). Off unless
|
|
900
|
-
* `sessions.cacheKeepalive.enabled` is true — no hot-reload, matching
|
|
901
|
-
* startCacheKeepalive above: config changes need a daemon restart.
|
|
902
|
-
*/
|
|
903
|
-
function startSessionKeepalive() {
|
|
904
|
-
const config = daemonConfig.sessions.cacheKeepalive;
|
|
905
|
-
if (!config.enabled) {
|
|
906
|
-
process.stderr.write("[pai-daemon] Session keepalive: disabled (sessions.cacheKeepalive.enabled is false)\n");
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
|
-
const tick = () => {
|
|
910
|
-
runSessionKeepaliveTick(config).catch((e) => {
|
|
911
|
-
process.stderr.write(`[pai-daemon] Session keepalive tick error: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
912
|
-
});
|
|
913
|
-
};
|
|
914
|
-
const timer = setInterval(tick, SESSION_KEEPALIVE_TICK_MS);
|
|
915
|
-
if (timer.unref) timer.unref();
|
|
916
|
-
process.stderr.write(`[pai-daemon] Session keepalive: every ${SESSION_KEEPALIVE_TICK_MS / 1e3}s (idle >= ${config.idleMinutes}min, hours ${config.activeHours}, max ${config.maxBeats} beats/stretch)\n`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
//#endregion
|
|
920
|
-
//#region src/daemon/daemon/dispatcher.ts
|
|
921
|
-
/**
|
|
922
|
-
* Tool dispatcher — maps IPC method names to PAI tool functions.
|
|
923
|
-
*/
|
|
924
|
-
/**
|
|
925
|
-
* Dispatch an IPC tool call to the appropriate tool function.
|
|
926
|
-
* Returns the tool result or throws on unknown/failed methods.
|
|
927
|
-
*/
|
|
928
|
-
async function dispatchTool(method, params) {
|
|
929
|
-
const p = params;
|
|
930
|
-
switch (method) {
|
|
931
|
-
case "memory_search": return toolMemorySearch(registryBackend, storageBackend, p);
|
|
932
|
-
case "memory_get": return toolMemoryGet(registryBackend, p);
|
|
933
|
-
case "memory_outline": return toolMemoryOutline(registryBackend, p);
|
|
934
|
-
case "project_info": return toolProjectInfo(registryBackend, p);
|
|
935
|
-
case "project_list": return toolProjectList(registryBackend, p);
|
|
936
|
-
case "session_list": return toolSessionList(registryBackend, p);
|
|
937
|
-
case "registry_search": return toolRegistrySearch(registryBackend, p);
|
|
938
|
-
case "project_detect": return toolProjectDetect(p);
|
|
939
|
-
case "project_health": return toolProjectHealth(registryBackend, p);
|
|
940
|
-
case "project_todo": return toolProjectTodo(registryBackend, p);
|
|
941
|
-
case "memory_wakeup": return toolMemoryWakeup(registryBackend, p);
|
|
942
|
-
case "memory_taxonomy": return toolMemoryTaxonomy(registryBackend, storageBackend, p);
|
|
943
|
-
case "topic_check": return detectTopicShift(registryBackend, storageBackend, p);
|
|
944
|
-
case "session_auto_route": return toolSessionRoute(registryBackend, storageBackend, p);
|
|
945
|
-
case "zettel_explore":
|
|
946
|
-
case "zettel_health":
|
|
947
|
-
case "zettel_surprise":
|
|
948
|
-
case "zettel_suggest":
|
|
949
|
-
case "zettel_converse":
|
|
950
|
-
case "zettel_themes":
|
|
951
|
-
case "zettel_god_notes":
|
|
952
|
-
case "zettel_communities": {
|
|
953
|
-
const { toolZettelExplore, toolZettelHealth, toolZettelSurprise, toolZettelSuggest, toolZettelConverse, toolZettelThemes, toolZettelGodNotes, toolZettelCommunities } = await import("./tools-C-_l0o8Z.mjs");
|
|
954
|
-
switch (method) {
|
|
955
|
-
case "zettel_explore": return toolZettelExplore(storageBackend, p);
|
|
956
|
-
case "zettel_health": return toolZettelHealth(storageBackend, p);
|
|
957
|
-
case "zettel_surprise": return toolZettelSurprise(storageBackend, p);
|
|
958
|
-
case "zettel_suggest": return toolZettelSuggest(storageBackend, p);
|
|
959
|
-
case "zettel_converse": return toolZettelConverse(storageBackend, p);
|
|
960
|
-
case "zettel_themes": return toolZettelThemes(storageBackend, p);
|
|
961
|
-
case "zettel_god_notes": return toolZettelGodNotes(storageBackend, p);
|
|
962
|
-
case "zettel_communities": return toolZettelCommunities(storageBackend, p);
|
|
963
|
-
}
|
|
964
|
-
break;
|
|
965
|
-
}
|
|
966
|
-
case "graph_clusters": {
|
|
967
|
-
const { handleGraphClusters } = await import("./clusters-BCtD3fbe.mjs");
|
|
968
|
-
return handleGraphClusters(storageBackend, p);
|
|
969
|
-
}
|
|
970
|
-
case "graph_neighborhood": {
|
|
971
|
-
const { handleGraphNeighborhood } = await import("./neighborhood-2FsmzoxG.mjs");
|
|
972
|
-
return handleGraphNeighborhood(storageBackend, p);
|
|
973
|
-
}
|
|
974
|
-
case "graph_note_context": {
|
|
975
|
-
const { handleGraphNoteContext } = await import("./note-context-De63k8na.mjs");
|
|
976
|
-
return handleGraphNoteContext(storageBackend, p);
|
|
977
|
-
}
|
|
978
|
-
case "graph_trace": {
|
|
979
|
-
const { handleGraphTrace } = await import("./trace-CtV0RO6n.mjs");
|
|
980
|
-
return handleGraphTrace(storageBackend, p);
|
|
981
|
-
}
|
|
982
|
-
case "graph_latent_ideas": {
|
|
983
|
-
const { handleGraphLatentIdeas } = await import("./latent-ideas-wwAXquGe.mjs");
|
|
984
|
-
return handleGraphLatentIdeas(storageBackend, p);
|
|
985
|
-
}
|
|
986
|
-
case "idea_materialize": {
|
|
987
|
-
const { handleIdeaMaterialize } = await import("./latent-ideas-wwAXquGe.mjs");
|
|
988
|
-
if (!daemonConfig.vaultPath) throw new Error("idea_materialize requires vaultPath to be configured in the daemon config");
|
|
989
|
-
return handleIdeaMaterialize(p, daemonConfig.vaultPath);
|
|
990
|
-
}
|
|
991
|
-
case "kg_add":
|
|
992
|
-
case "kg_query":
|
|
993
|
-
case "kg_invalidate":
|
|
994
|
-
case "kg_contradictions": {
|
|
995
|
-
const { toolKgAdd, toolKgQuery, toolKgInvalidate, toolKgContradictions } = await import("./tools-C-_l0o8Z.mjs");
|
|
996
|
-
if (!storageBackend.supportsPostgresFeatures) throw new Error(`${method} requires a Postgres storage backend`);
|
|
997
|
-
switch (method) {
|
|
998
|
-
case "kg_add": return toolKgAdd(storageBackend, p);
|
|
999
|
-
case "kg_query": return toolKgQuery(storageBackend, p);
|
|
1000
|
-
case "kg_invalidate": return toolKgInvalidate(storageBackend, p);
|
|
1001
|
-
case "kg_contradictions": return toolKgContradictions(storageBackend, p);
|
|
1002
|
-
}
|
|
1003
|
-
break;
|
|
1004
|
-
}
|
|
1005
|
-
case "memory_tunnels": {
|
|
1006
|
-
const { toolMemoryTunnels } = await import("./tools-C-_l0o8Z.mjs");
|
|
1007
|
-
return toolMemoryTunnels(registryBackend, storageBackend, p);
|
|
1008
|
-
}
|
|
1009
|
-
case "memory_feedback": return await toolMemoryFeedback(storageBackend, p);
|
|
1010
|
-
case "memory_kg_search":
|
|
1011
|
-
if (!storageBackend.supportsPostgresFeatures) throw new Error("memory_kg_search requires a Postgres storage backend for KG triple expansion");
|
|
1012
|
-
return await toolMemoryKgSearch(storageBackend, p);
|
|
1013
|
-
default: throw new Error(`Unknown method: ${method}`);
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
//#endregion
|
|
1018
|
-
//#region src/daemon/daemon/handler.ts
|
|
1019
|
-
function sendResponse(socket, response) {
|
|
1020
|
-
try {
|
|
1021
|
-
socket.write(JSON.stringify(response) + "\n");
|
|
1022
|
-
} catch {}
|
|
1023
|
-
}
|
|
1024
|
-
/**
|
|
1025
|
-
* Handle a single IPC request.
|
|
1026
|
-
*/
|
|
1027
|
-
async function handleRequest(request, socket) {
|
|
1028
|
-
const { id, method, params } = request;
|
|
1029
|
-
if (method === "status") {
|
|
1030
|
-
const dbStats = await (async () => {
|
|
1031
|
-
try {
|
|
1032
|
-
if (!storageBackend) return null;
|
|
1033
|
-
const fedStats = await storageBackend.getStats();
|
|
1034
|
-
const projects = await registryBackend.countProjects();
|
|
1035
|
-
return {
|
|
1036
|
-
files: fedStats.files,
|
|
1037
|
-
chunks: fedStats.chunks,
|
|
1038
|
-
projects
|
|
1039
|
-
};
|
|
1040
|
-
} catch {
|
|
1041
|
-
return null;
|
|
1042
|
-
}
|
|
1043
|
-
})();
|
|
1044
|
-
sendResponse(socket, {
|
|
1045
|
-
id,
|
|
1046
|
-
ok: true,
|
|
1047
|
-
result: {
|
|
1048
|
-
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
1049
|
-
indexInProgress,
|
|
1050
|
-
lastIndexTime: lastIndexTime ? new Date(lastIndexTime).toISOString() : null,
|
|
1051
|
-
indexIntervalSecs: daemonConfig.indexIntervalSecs,
|
|
1052
|
-
embedInProgress,
|
|
1053
|
-
lastEmbedTime: lastEmbedTime ? new Date(lastEmbedTime).toISOString() : null,
|
|
1054
|
-
embedIntervalSecs: daemonConfig.embedIntervalSecs,
|
|
1055
|
-
socketPath: daemonConfig.socketPath,
|
|
1056
|
-
storageBackend: storageBackend?.backendType ?? "initializing",
|
|
1057
|
-
db: dbStats,
|
|
1058
|
-
vaultIndexInProgress,
|
|
1059
|
-
lastVaultIndexTime: lastVaultIndexTime ? new Date(lastVaultIndexTime).toISOString() : null,
|
|
1060
|
-
vaultPath: daemonConfig.vaultPath ?? null,
|
|
1061
|
-
workQueue: getStats(),
|
|
1062
|
-
backendOutage: getBackendOutage()
|
|
1063
|
-
}
|
|
1064
|
-
});
|
|
1065
|
-
socket.end();
|
|
1066
|
-
return;
|
|
1067
|
-
}
|
|
1068
|
-
if (method === "index_now") {
|
|
1069
|
-
runIndex().catch((e) => {
|
|
1070
|
-
process.stderr.write(`[pai-daemon] index_now error: ${e}\n`);
|
|
1071
|
-
});
|
|
1072
|
-
sendResponse(socket, {
|
|
1073
|
-
id,
|
|
1074
|
-
ok: true,
|
|
1075
|
-
result: { triggered: true }
|
|
1076
|
-
});
|
|
1077
|
-
socket.end();
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
if (method === "vault_index_now") {
|
|
1081
|
-
runVaultIndex().catch((e) => {
|
|
1082
|
-
process.stderr.write(`[pai-daemon] vault_index_now error: ${e}\n`);
|
|
1083
|
-
});
|
|
1084
|
-
sendResponse(socket, {
|
|
1085
|
-
id,
|
|
1086
|
-
ok: true,
|
|
1087
|
-
result: { triggered: true }
|
|
1088
|
-
});
|
|
1089
|
-
socket.end();
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
if (method === "notification_get_config") {
|
|
1093
|
-
sendResponse(socket, {
|
|
1094
|
-
id,
|
|
1095
|
-
ok: true,
|
|
1096
|
-
result: {
|
|
1097
|
-
config: notificationConfig,
|
|
1098
|
-
activeChannels: Object.entries(notificationConfig.channels).filter(([ch, cfg]) => ch !== "voice" && cfg.enabled).map(([ch]) => ch)
|
|
1099
|
-
}
|
|
1100
|
-
});
|
|
1101
|
-
socket.end();
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
if (method === "notification_set_config") {
|
|
1105
|
-
try {
|
|
1106
|
-
const p = params;
|
|
1107
|
-
const updated = patchNotificationConfig({
|
|
1108
|
-
mode: p.mode,
|
|
1109
|
-
channels: p.channels,
|
|
1110
|
-
routing: p.routing
|
|
1111
|
-
});
|
|
1112
|
-
setNotificationConfig(updated);
|
|
1113
|
-
sendResponse(socket, {
|
|
1114
|
-
id,
|
|
1115
|
-
ok: true,
|
|
1116
|
-
result: { config: updated }
|
|
1117
|
-
});
|
|
1118
|
-
} catch (e) {
|
|
1119
|
-
sendResponse(socket, {
|
|
1120
|
-
id,
|
|
1121
|
-
ok: false,
|
|
1122
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
socket.end();
|
|
1126
|
-
return;
|
|
1127
|
-
}
|
|
1128
|
-
if (method === "notification_send") {
|
|
1129
|
-
const p = params;
|
|
1130
|
-
if (!p.message) {
|
|
1131
|
-
sendResponse(socket, {
|
|
1132
|
-
id,
|
|
1133
|
-
ok: false,
|
|
1134
|
-
error: "notification_send: message is required"
|
|
1135
|
-
});
|
|
1136
|
-
socket.end();
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
routeNotification({
|
|
1140
|
-
event: p.event ?? "info",
|
|
1141
|
-
message: p.message,
|
|
1142
|
-
title: p.title
|
|
1143
|
-
}, notificationConfig).then((result) => {
|
|
1144
|
-
sendResponse(socket, {
|
|
1145
|
-
id,
|
|
1146
|
-
ok: true,
|
|
1147
|
-
result
|
|
1148
|
-
});
|
|
1149
|
-
socket.end();
|
|
1150
|
-
}).catch((e) => {
|
|
1151
|
-
sendResponse(socket, {
|
|
1152
|
-
id,
|
|
1153
|
-
ok: false,
|
|
1154
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1155
|
-
});
|
|
1156
|
-
socket.end();
|
|
1157
|
-
});
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
if (method === "observation_store") {
|
|
1161
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1162
|
-
sendResponse(socket, {
|
|
1163
|
-
id,
|
|
1164
|
-
ok: false,
|
|
1165
|
-
error: "Observations require Postgres backend"
|
|
1166
|
-
});
|
|
1167
|
-
socket.end();
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
try {
|
|
1171
|
-
const p = params;
|
|
1172
|
-
sendResponse(socket, {
|
|
1173
|
-
id,
|
|
1174
|
-
ok: true,
|
|
1175
|
-
result: {
|
|
1176
|
-
ok: true,
|
|
1177
|
-
id: await storageBackend.storeObservationWithProject(registryBackend, {
|
|
1178
|
-
session_id: p.session_id,
|
|
1179
|
-
type: p.type,
|
|
1180
|
-
title: p.title,
|
|
1181
|
-
narrative: p.narrative ?? null,
|
|
1182
|
-
tool_name: p.tool_name,
|
|
1183
|
-
tool_input_summary: p.tool_input_summary ?? void 0,
|
|
1184
|
-
files_read: p.files_read ?? [],
|
|
1185
|
-
files_modified: p.files_modified ?? [],
|
|
1186
|
-
concepts: p.concepts ?? [],
|
|
1187
|
-
cwd: p.cwd
|
|
1188
|
-
})
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
} catch (e) {
|
|
1192
|
-
sendResponse(socket, {
|
|
1193
|
-
id,
|
|
1194
|
-
ok: false,
|
|
1195
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
socket.end();
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
if (method === "skill_telemetry_record") {
|
|
1202
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1203
|
-
sendResponse(socket, {
|
|
1204
|
-
id,
|
|
1205
|
-
ok: false,
|
|
1206
|
-
error: "Skill telemetry requires Postgres backend"
|
|
1207
|
-
});
|
|
1208
|
-
socket.end();
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
try {
|
|
1212
|
-
const p = params;
|
|
1213
|
-
let project_slug = null;
|
|
1214
|
-
if (p.cwd) {
|
|
1215
|
-
const row = await registryBackend.findProjectByCwdPrefix(p.cwd);
|
|
1216
|
-
if (row) project_slug = row.slug;
|
|
1217
|
-
}
|
|
1218
|
-
await storageBackend.recordSkillInvocation({
|
|
1219
|
-
skill_name: p.skill_name,
|
|
1220
|
-
source: p.source,
|
|
1221
|
-
scope: p.scope,
|
|
1222
|
-
project_slug
|
|
1223
|
-
});
|
|
1224
|
-
sendResponse(socket, {
|
|
1225
|
-
id,
|
|
1226
|
-
ok: true,
|
|
1227
|
-
result: { ok: true }
|
|
1228
|
-
});
|
|
1229
|
-
} catch (e) {
|
|
1230
|
-
sendResponse(socket, {
|
|
1231
|
-
id,
|
|
1232
|
-
ok: false,
|
|
1233
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
socket.end();
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
if (method === "skill_telemetry_query") {
|
|
1240
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1241
|
-
sendResponse(socket, {
|
|
1242
|
-
id,
|
|
1243
|
-
ok: false,
|
|
1244
|
-
error: "Skill telemetry requires Postgres backend"
|
|
1245
|
-
});
|
|
1246
|
-
socket.end();
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
try {
|
|
1250
|
-
const p = params;
|
|
1251
|
-
sendResponse(socket, {
|
|
1252
|
-
id,
|
|
1253
|
-
ok: true,
|
|
1254
|
-
result: await storageBackend.querySkillTelemetry({
|
|
1255
|
-
scope: p.scope,
|
|
1256
|
-
status: p.status,
|
|
1257
|
-
limit: p.limit
|
|
1258
|
-
})
|
|
1259
|
-
});
|
|
1260
|
-
} catch (e) {
|
|
1261
|
-
sendResponse(socket, {
|
|
1262
|
-
id,
|
|
1263
|
-
ok: false,
|
|
1264
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
socket.end();
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
if (method === "observation_query") {
|
|
1271
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1272
|
-
sendResponse(socket, {
|
|
1273
|
-
id,
|
|
1274
|
-
ok: false,
|
|
1275
|
-
error: "Observations require Postgres backend"
|
|
1276
|
-
});
|
|
1277
|
-
socket.end();
|
|
1278
|
-
return;
|
|
1279
|
-
}
|
|
1280
|
-
try {
|
|
1281
|
-
const p = params;
|
|
1282
|
-
sendResponse(socket, {
|
|
1283
|
-
id,
|
|
1284
|
-
ok: true,
|
|
1285
|
-
result: await storageBackend.queryObservations({
|
|
1286
|
-
projectId: p.project_id,
|
|
1287
|
-
sessionId: p.session_id,
|
|
1288
|
-
type: p.type,
|
|
1289
|
-
limit: p.limit,
|
|
1290
|
-
offset: p.offset
|
|
1291
|
-
})
|
|
1292
|
-
});
|
|
1293
|
-
} catch (e) {
|
|
1294
|
-
sendResponse(socket, {
|
|
1295
|
-
id,
|
|
1296
|
-
ok: false,
|
|
1297
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
socket.end();
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
if (method === "observation_recent") {
|
|
1304
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1305
|
-
sendResponse(socket, {
|
|
1306
|
-
id,
|
|
1307
|
-
ok: false,
|
|
1308
|
-
error: "Observations require Postgres backend"
|
|
1309
|
-
});
|
|
1310
|
-
socket.end();
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
try {
|
|
1314
|
-
const p = params;
|
|
1315
|
-
const limit = p.limit ?? 20;
|
|
1316
|
-
let resolvedProjectId = p.project_id;
|
|
1317
|
-
let resolvedProjectSlug;
|
|
1318
|
-
if (resolvedProjectId === void 0 && p.cwd) {
|
|
1319
|
-
const row = await registryBackend.findProjectByCwdPrefix(p.cwd);
|
|
1320
|
-
if (row) {
|
|
1321
|
-
resolvedProjectId = row.id;
|
|
1322
|
-
resolvedProjectSlug = row.slug;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
let rows;
|
|
1326
|
-
if (resolvedProjectId !== void 0) rows = await storageBackend.queryRecentObservations(resolvedProjectId, limit);
|
|
1327
|
-
else rows = await storageBackend.queryObservations({ limit });
|
|
1328
|
-
sendResponse(socket, {
|
|
1329
|
-
id,
|
|
1330
|
-
ok: true,
|
|
1331
|
-
result: {
|
|
1332
|
-
rows,
|
|
1333
|
-
project_slug: resolvedProjectSlug
|
|
1334
|
-
}
|
|
1335
|
-
});
|
|
1336
|
-
} catch (e) {
|
|
1337
|
-
sendResponse(socket, {
|
|
1338
|
-
id,
|
|
1339
|
-
ok: false,
|
|
1340
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1341
|
-
});
|
|
1342
|
-
}
|
|
1343
|
-
socket.end();
|
|
1344
|
-
return;
|
|
1345
|
-
}
|
|
1346
|
-
if (method === "observation_list") {
|
|
1347
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1348
|
-
sendResponse(socket, {
|
|
1349
|
-
id,
|
|
1350
|
-
ok: false,
|
|
1351
|
-
error: "Observations require Postgres backend"
|
|
1352
|
-
});
|
|
1353
|
-
socket.end();
|
|
1354
|
-
return;
|
|
1355
|
-
}
|
|
1356
|
-
try {
|
|
1357
|
-
const p = params;
|
|
1358
|
-
let projectId;
|
|
1359
|
-
if (p.project_slug) projectId = (await registryBackend.getProjectBySlug(p.project_slug))?.id;
|
|
1360
|
-
sendResponse(socket, {
|
|
1361
|
-
id,
|
|
1362
|
-
ok: true,
|
|
1363
|
-
result: await storageBackend.queryObservations({
|
|
1364
|
-
projectId,
|
|
1365
|
-
sessionId: p.session_id,
|
|
1366
|
-
type: p.type,
|
|
1367
|
-
limit: p.limit ?? 20,
|
|
1368
|
-
offset: p.offset ?? 0
|
|
1369
|
-
})
|
|
1370
|
-
});
|
|
1371
|
-
} catch (e) {
|
|
1372
|
-
sendResponse(socket, {
|
|
1373
|
-
id,
|
|
1374
|
-
ok: false,
|
|
1375
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
socket.end();
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1381
|
-
if (method === "observation_stats") {
|
|
1382
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1383
|
-
sendResponse(socket, {
|
|
1384
|
-
id,
|
|
1385
|
-
ok: false,
|
|
1386
|
-
error: "Observations require Postgres backend"
|
|
1387
|
-
});
|
|
1388
|
-
socket.end();
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
try {
|
|
1392
|
-
sendResponse(socket, {
|
|
1393
|
-
id,
|
|
1394
|
-
ok: true,
|
|
1395
|
-
result: await storageBackend.getObservationStats()
|
|
1396
|
-
});
|
|
1397
|
-
} catch (e) {
|
|
1398
|
-
sendResponse(socket, {
|
|
1399
|
-
id,
|
|
1400
|
-
ok: false,
|
|
1401
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1402
|
-
});
|
|
1403
|
-
}
|
|
1404
|
-
socket.end();
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
if (method === "session_summary_store") {
|
|
1408
|
-
if (!storageBackend?.supportsPostgresFeatures) {
|
|
1409
|
-
sendResponse(socket, {
|
|
1410
|
-
id,
|
|
1411
|
-
ok: false,
|
|
1412
|
-
error: "Session summaries require Postgres backend"
|
|
1413
|
-
});
|
|
1414
|
-
socket.end();
|
|
1415
|
-
return;
|
|
1416
|
-
}
|
|
1417
|
-
try {
|
|
1418
|
-
const p = params;
|
|
1419
|
-
let resolvedProjectId = p.project_id ?? null;
|
|
1420
|
-
let resolvedProjectSlug = p.project_slug ?? null;
|
|
1421
|
-
if (resolvedProjectId === null && p.cwd) {
|
|
1422
|
-
const row = await registryBackend.findProjectByCwdPrefix(p.cwd);
|
|
1423
|
-
if (row) {
|
|
1424
|
-
resolvedProjectId = row.id;
|
|
1425
|
-
resolvedProjectSlug = row.slug;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
await storageBackend.storeSessionSummary({
|
|
1429
|
-
session_id: p.session_id,
|
|
1430
|
-
project_id: resolvedProjectId,
|
|
1431
|
-
project_slug: resolvedProjectSlug,
|
|
1432
|
-
request: p.request ?? null,
|
|
1433
|
-
investigated: p.investigated ?? null,
|
|
1434
|
-
learned: p.learned ?? null,
|
|
1435
|
-
completed: p.completed ?? null,
|
|
1436
|
-
next_steps: p.next_steps ?? null,
|
|
1437
|
-
observation_count: p.observation_count ?? 0
|
|
1438
|
-
});
|
|
1439
|
-
sendResponse(socket, {
|
|
1440
|
-
id,
|
|
1441
|
-
ok: true,
|
|
1442
|
-
result: { ok: true }
|
|
1443
|
-
});
|
|
1444
|
-
} catch (e) {
|
|
1445
|
-
sendResponse(socket, {
|
|
1446
|
-
id,
|
|
1447
|
-
ok: false,
|
|
1448
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
socket.end();
|
|
1452
|
-
return;
|
|
1453
|
-
}
|
|
1454
|
-
if (method === "work_queue_enqueue") {
|
|
1455
|
-
try {
|
|
1456
|
-
const p = params;
|
|
1457
|
-
if (!p.type) {
|
|
1458
|
-
sendResponse(socket, {
|
|
1459
|
-
id,
|
|
1460
|
-
ok: false,
|
|
1461
|
-
error: "work_queue_enqueue: type is required"
|
|
1462
|
-
});
|
|
1463
|
-
socket.end();
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
const item = enqueue({
|
|
1467
|
-
type: p.type,
|
|
1468
|
-
priority: p.priority,
|
|
1469
|
-
payload: p.payload ?? {},
|
|
1470
|
-
maxAttempts: p.maxAttempts
|
|
1471
|
-
});
|
|
1472
|
-
/* @__PURE__ */ notifyNewWork();
|
|
1473
|
-
sendResponse(socket, {
|
|
1474
|
-
id,
|
|
1475
|
-
ok: true,
|
|
1476
|
-
result: {
|
|
1477
|
-
id: item.id,
|
|
1478
|
-
status: item.status
|
|
1479
|
-
}
|
|
1480
|
-
});
|
|
1481
|
-
} catch (e) {
|
|
1482
|
-
sendResponse(socket, {
|
|
1483
|
-
id,
|
|
1484
|
-
ok: false,
|
|
1485
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
socket.end();
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
if (method === "work_queue_stats") {
|
|
1492
|
-
sendResponse(socket, {
|
|
1493
|
-
id,
|
|
1494
|
-
ok: true,
|
|
1495
|
-
result: getStats()
|
|
1496
|
-
});
|
|
1497
|
-
socket.end();
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
try {
|
|
1501
|
-
sendResponse(socket, {
|
|
1502
|
-
id,
|
|
1503
|
-
ok: true,
|
|
1504
|
-
result: await dispatchTool(method, params)
|
|
1505
|
-
});
|
|
1506
|
-
} catch (e) {
|
|
1507
|
-
sendResponse(socket, {
|
|
1508
|
-
id,
|
|
1509
|
-
ok: false,
|
|
1510
|
-
error: e instanceof Error ? e.message : String(e)
|
|
1511
|
-
});
|
|
1512
|
-
}
|
|
1513
|
-
socket.end();
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
//#endregion
|
|
1517
|
-
//#region src/storage/postgres/fts-migration.ts
|
|
1518
|
-
const DONE_MARKER = "fts:simple";
|
|
1519
|
-
const PROGRESS_PREFIX = "fts:simple:inprogress:";
|
|
1520
|
-
const BATCH_SIZE = 1e3;
|
|
1521
|
-
const MAX_ATTEMPTS = 5;
|
|
1522
|
-
const RETRYABLE_CODES = new Set(["40P01", "40001"]);
|
|
1523
|
-
function sleep(ms) {
|
|
1524
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1525
|
-
}
|
|
1526
|
-
async function migrateFtsConfig(backend) {
|
|
1527
|
-
const pool = backend.getPool();
|
|
1528
|
-
if ((await pool.query(`SELECT 1 FROM information_schema.tables WHERE table_name = 'pai_chunks'`)).rowCount === 0) return;
|
|
1529
|
-
const comment = (await pool.query(`SELECT col_description('pai_chunks'::regclass, ordinal_position) AS comment
|
|
1530
|
-
FROM information_schema.columns
|
|
1531
|
-
WHERE table_name = 'pai_chunks' AND column_name = 'fts_vector'`)).rows[0]?.comment ?? null;
|
|
1532
|
-
if (comment === DONE_MARKER) return;
|
|
1533
|
-
const resumeId = comment?.startsWith(PROGRESS_PREFIX) ? comment.slice(22) : "";
|
|
1534
|
-
process.stderr.write("[pai-postgres] FTS config migration: starting (english -> simple)\n");
|
|
1535
|
-
if ((await pool.query(`SELECT 1 FROM pg_proc WHERE proname = 'pai_chunks_fts_update'`)).rowCount !== 0) {
|
|
1536
|
-
await pool.query(`
|
|
1537
|
-
CREATE OR REPLACE FUNCTION pai_chunks_fts_update()
|
|
1538
|
-
RETURNS TRIGGER AS $$
|
|
1539
|
-
BEGIN
|
|
1540
|
-
NEW.fts_vector := to_tsvector('simple', COALESCE(NEW.text, ''));
|
|
1541
|
-
RETURN NEW;
|
|
1542
|
-
END;
|
|
1543
|
-
$$ LANGUAGE plpgsql;
|
|
1544
|
-
`);
|
|
1545
|
-
process.stderr.write("[pai-postgres] FTS config migration: trigger function now uses 'simple'\n");
|
|
1546
|
-
}
|
|
1547
|
-
let lastId = resumeId || null;
|
|
1548
|
-
if (lastId !== null) process.stderr.write(`[pai-postgres] FTS config migration: resuming from ${lastId}\n`);
|
|
1549
|
-
let totalUpdated = 0;
|
|
1550
|
-
let batchN = 0;
|
|
1551
|
-
while (true) {
|
|
1552
|
-
const sql = lastId === null ? `SELECT id FROM pai_chunks ORDER BY id LIMIT $1` : `SELECT id FROM pai_chunks WHERE id > $2 ORDER BY id LIMIT $1`;
|
|
1553
|
-
const params = lastId === null ? [BATCH_SIZE] : [BATCH_SIZE, lastId];
|
|
1554
|
-
const idRows = await pool.query(sql, params);
|
|
1555
|
-
if (idRows.rows.length === 0) break;
|
|
1556
|
-
const ids = idRows.rows.map((r) => r.id);
|
|
1557
|
-
for (let attempt = 1;; attempt++) try {
|
|
1558
|
-
const result = await pool.query(`UPDATE pai_chunks c SET fts_vector = to_tsvector('simple', COALESCE(c.text, ''))
|
|
1559
|
-
FROM (SELECT id FROM pai_chunks WHERE id = ANY($1::text[]) ORDER BY id FOR UPDATE) s
|
|
1560
|
-
WHERE c.id = s.id
|
|
1561
|
-
AND c.fts_vector IS DISTINCT FROM to_tsvector('simple', COALESCE(c.text, ''))`, [ids]);
|
|
1562
|
-
totalUpdated += result.rowCount ?? 0;
|
|
1563
|
-
break;
|
|
1564
|
-
} catch (err) {
|
|
1565
|
-
const code = err.code;
|
|
1566
|
-
if (attempt >= MAX_ATTEMPTS || !code || !RETRYABLE_CODES.has(code)) throw err;
|
|
1567
|
-
await sleep(200 * attempt);
|
|
1568
|
-
}
|
|
1569
|
-
lastId = ids[ids.length - 1];
|
|
1570
|
-
batchN++;
|
|
1571
|
-
if (batchN % 20 === 0) {
|
|
1572
|
-
await pool.query(`COMMENT ON COLUMN pai_chunks.fts_vector IS '${PROGRESS_PREFIX}${lastId.replace(/'/g, "''")}'`);
|
|
1573
|
-
process.stderr.write(`[pai-postgres] FTS config migration: ${totalUpdated} chunks rebuilt so far...\n`);
|
|
1574
|
-
}
|
|
1575
|
-
if (idRows.rows.length < BATCH_SIZE) break;
|
|
1576
|
-
}
|
|
1577
|
-
await pool.query(`COMMENT ON COLUMN pai_chunks.fts_vector IS '${DONE_MARKER}'`);
|
|
1578
|
-
process.stderr.write(`[pai-postgres] FTS config migration: done, ${totalUpdated} chunks rebuilt\n`);
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
//#endregion
|
|
1582
|
-
//#region src/daemon/daemon/server.ts
|
|
1583
|
-
/**
|
|
1584
|
-
* IPC server and daemon entry point.
|
|
1585
|
-
* Owns: isSocketLive, startIpcServer, serve (exported).
|
|
1586
|
-
*/
|
|
1587
|
-
/**
|
|
1588
|
-
* Check whether an existing socket file is actually being served by a live process.
|
|
1589
|
-
*/
|
|
1590
|
-
function isSocketLive(path) {
|
|
1591
|
-
return new Promise((resolve) => {
|
|
1592
|
-
const client = connect(path);
|
|
1593
|
-
const timer = setTimeout(() => {
|
|
1594
|
-
client.destroy();
|
|
1595
|
-
resolve(false);
|
|
1596
|
-
}, 500);
|
|
1597
|
-
client.on("connect", () => {
|
|
1598
|
-
clearTimeout(timer);
|
|
1599
|
-
client.end();
|
|
1600
|
-
resolve(true);
|
|
1601
|
-
});
|
|
1602
|
-
client.on("error", () => {
|
|
1603
|
-
clearTimeout(timer);
|
|
1604
|
-
resolve(false);
|
|
1605
|
-
});
|
|
1606
|
-
});
|
|
1607
|
-
}
|
|
1608
|
-
/**
|
|
1609
|
-
* Start the Unix Domain Socket IPC server.
|
|
1610
|
-
*/
|
|
1611
|
-
async function startIpcServer(socketPath) {
|
|
1612
|
-
if (existsSync(socketPath)) {
|
|
1613
|
-
if (await isSocketLive(socketPath)) throw new Error("Another daemon is already running — socket is live. Aborting startup.");
|
|
1614
|
-
try {
|
|
1615
|
-
unlinkSync(socketPath);
|
|
1616
|
-
process.stderr.write("[pai-daemon] Removed stale socket file.\n");
|
|
1617
|
-
} catch {}
|
|
1618
|
-
}
|
|
1619
|
-
const server = createServer((socket) => {
|
|
1620
|
-
let buffer = "";
|
|
1621
|
-
socket.on("data", (chunk) => {
|
|
1622
|
-
buffer += chunk.toString();
|
|
1623
|
-
let nl;
|
|
1624
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
1625
|
-
const line = buffer.slice(0, nl);
|
|
1626
|
-
buffer = buffer.slice(nl + 1);
|
|
1627
|
-
if (line.trim() === "") continue;
|
|
1628
|
-
let request;
|
|
1629
|
-
try {
|
|
1630
|
-
request = JSON.parse(line);
|
|
1631
|
-
} catch {
|
|
1632
|
-
sendResponse(socket, {
|
|
1633
|
-
id: "?",
|
|
1634
|
-
ok: false,
|
|
1635
|
-
error: "Invalid JSON"
|
|
1636
|
-
});
|
|
1637
|
-
socket.destroy();
|
|
1638
|
-
return;
|
|
1639
|
-
}
|
|
1640
|
-
handleRequest(request, socket).catch((e) => {
|
|
1641
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1642
|
-
sendResponse(socket, {
|
|
1643
|
-
id: request.id,
|
|
1644
|
-
ok: false,
|
|
1645
|
-
error: msg
|
|
1646
|
-
});
|
|
1647
|
-
socket.destroy();
|
|
1648
|
-
});
|
|
1649
|
-
}
|
|
1650
|
-
});
|
|
1651
|
-
socket.on("error", () => {});
|
|
1652
|
-
});
|
|
1653
|
-
server.on("error", (e) => {
|
|
1654
|
-
process.stderr.write(`[pai-daemon] IPC server error: ${e}\n`);
|
|
1655
|
-
});
|
|
1656
|
-
server.listen(socketPath, () => {
|
|
1657
|
-
process.stderr.write(`[pai-daemon] IPC server listening on ${socketPath}\n`);
|
|
1658
|
-
});
|
|
1659
|
-
return server;
|
|
1660
|
-
}
|
|
1661
|
-
async function serve(config) {
|
|
1662
|
-
setDaemonConfig(config);
|
|
1663
|
-
setStartTime(Date.now());
|
|
1664
|
-
setNotificationConfig(loadNotificationConfig());
|
|
1665
|
-
process.stderr.write("[pai-daemon] Starting daemon...\n");
|
|
1666
|
-
process.stderr.write(`[pai-daemon] Socket: ${config.socketPath}\n`);
|
|
1667
|
-
process.stderr.write(`[pai-daemon] Storage backend: ${config.storageBackend}\n`);
|
|
1668
|
-
const { notificationConfig } = await import("./state-DW8zdweW.mjs");
|
|
1669
|
-
process.stderr.write(`[pai-daemon] Notification mode: ${notificationConfig.mode}\n`);
|
|
1670
|
-
try {
|
|
1671
|
-
setPriority(process.pid, 10);
|
|
1672
|
-
} catch {}
|
|
1673
|
-
configureEmbeddingModel(config.embeddingModel);
|
|
1674
|
-
createRegistryBackend(config, { waitForPostgres: true }).then((backend) => {
|
|
1675
|
-
setRegistryBackend(backend);
|
|
1676
|
-
process.stderr.write(`[pai-daemon] Registry backend: ${backend.backendType}\n`);
|
|
1677
|
-
}).catch((e) => {
|
|
1678
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1679
|
-
process.stderr.write(`[pai-daemon] Fatal: Could not open registry: ${msg}\n`);
|
|
1680
|
-
process.exit(1);
|
|
1681
|
-
});
|
|
1682
|
-
const server = await startIpcServer(config.socketPath);
|
|
1683
|
-
startRegistryScanScheduler();
|
|
1684
|
-
startWorkerSupervisor();
|
|
1685
|
-
startCacheKeepalive();
|
|
1686
|
-
startSessionKeepalive();
|
|
1687
|
-
createStorageBackend(config, { waitForPostgres: true }).then((backend) => {
|
|
1688
|
-
setStorageBackend(backend);
|
|
1689
|
-
process.stderr.write(`[pai-daemon] Federation backend: ${backend.backendType}\n`);
|
|
1690
|
-
startIndexScheduler();
|
|
1691
|
-
if (backend.supportsPostgresFeatures) {
|
|
1692
|
-
startEmbedScheduler();
|
|
1693
|
-
migrateFtsConfig(backend).catch((e) => {
|
|
1694
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1695
|
-
process.stderr.write(`[pai-daemon] FTS config migration failed: ${msg}\n`);
|
|
1696
|
-
});
|
|
1697
|
-
} else process.stderr.write("[pai-daemon] Embed scheduler: disabled (SQLite backend)\n");
|
|
1698
|
-
loadQueue();
|
|
1699
|
-
startWorker();
|
|
1700
|
-
}).catch((e) => {
|
|
1701
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
1702
|
-
process.stderr.write(`[pai-daemon] Fatal: Could not open federation storage: ${msg}\n`);
|
|
1703
|
-
process.exit(1);
|
|
1704
|
-
});
|
|
1705
|
-
const shutdown = async (signal) => {
|
|
1706
|
-
process.stderr.write(`\n[pai-daemon] ${signal} received. Stopping.\n`);
|
|
1707
|
-
setShutdownRequested(true);
|
|
1708
|
-
if (indexSchedulerTimer) clearInterval(indexSchedulerTimer);
|
|
1709
|
-
if (embedSchedulerTimer) clearInterval(embedSchedulerTimer);
|
|
1710
|
-
if (cacheKeepaliveTimer) clearInterval(cacheKeepaliveTimer);
|
|
1711
|
-
stopWorker();
|
|
1712
|
-
server.close();
|
|
1713
|
-
const SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
1714
|
-
const POLL_INTERVAL_MS = 100;
|
|
1715
|
-
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
|
|
1716
|
-
if (indexInProgress || embedInProgress) {
|
|
1717
|
-
process.stderr.write(`[pai-daemon] Waiting for in-progress operations to finish (index=${indexInProgress}, embed=${embedInProgress})...\n`);
|
|
1718
|
-
while ((indexInProgress || embedInProgress) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
1719
|
-
if (indexInProgress || embedInProgress) process.stderr.write("[pai-daemon] Shutdown timeout reached — forcing exit.\n");
|
|
1720
|
-
else process.stderr.write("[pai-daemon] In-progress operations finished.\n");
|
|
1721
|
-
}
|
|
1722
|
-
try {
|
|
1723
|
-
await storageBackend.close();
|
|
1724
|
-
} catch {}
|
|
1725
|
-
try {
|
|
1726
|
-
unlinkSync(config.socketPath);
|
|
1727
|
-
} catch {}
|
|
1728
|
-
process.exit(0);
|
|
1729
|
-
};
|
|
1730
|
-
process.on("SIGINT", () => {
|
|
1731
|
-
shutdown("SIGINT").catch(() => process.exit(0));
|
|
1732
|
-
});
|
|
1733
|
-
process.on("SIGTERM", () => {
|
|
1734
|
-
shutdown("SIGTERM").catch(() => process.exit(0));
|
|
1735
|
-
});
|
|
1736
|
-
await new Promise(() => {});
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
//#endregion
|
|
1740
|
-
export { serve as t };
|
|
1741
|
-
//# sourceMappingURL=daemon-DXlaVCkw.mjs.map
|