@turmind/halo 1.6.7 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +2 -2
- package/bundled-docs/design/agentcore.md +27 -0
- package/bundled-docs/design/architecture.md +4 -4
- package/bundled-docs/design/background-dispatch.md +3 -3
- package/bundled-docs/design/cron.md +12 -10
- package/bundled-docs/design/halo-city.md +7 -7
- package/bundled-docs/design/session.md +99 -80
- package/bundled-docs/design/storage.md +66 -49
- package/bundled-docs/design/web.md +4 -0
- package/bundled-docs/design/ws.md +4 -3
- package/bundled-docs/dev/add-model-provider.md +1 -0
- package/bundled-docs/dev/api.md +15 -12
- package/bundled-docs/dev/deploy.md +2 -0
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/tools.md +3 -1
- package/bundled-docs/guide/cli.md +16 -0
- package/bundled-docs/guide/sessions.md +1 -1
- package/bundled-docs/guide/testing-agents-and-skills.md +2 -2
- package/bundled-docs/requirements/chat.md +1 -1
- package/dist/index.js +3435 -1789
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/schema.sql +5 -1
- package/admin-out/_next/static/chunks/app/page-3584adfe2f9a0913.js +0 -1
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_ssgManifest.js +0 -0
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--52ayDC9phpvb_95S7qHHq--><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/css/7b736202f0b1dbfd.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/e890aa0d13e94290.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/659eccb5db697b76.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-74f577c1b094f942.js"/><script src="/_next/static/chunks/1b86ba38-c29611e086b2ce25.js" async=""></script><script src="/_next/static/chunks/698-19872069fed27164.js" async=""></script><script src="/_next/static/chunks/main-app-340a5b58ed229c4d.js" async=""></script><script src="/_next/static/chunks/989-cb287787a4aca288.js" async=""></script><script src="/_next/static/chunks/app/layout-ea161287d934ba96.js" async=""></script><script src="/_next/static/chunks/1d2d5650-d8d130c85727fdbb.js" async=""></script><script src="/_next/static/chunks/87-6a2716c98a819c4e.js" async=""></script><script src="/_next/static/chunks/app/page-c9fb252d0cf34c20.js" async=""></script><meta name="next-size-adjust" content=""/><title>Halo - Multi-Agent Workspace</title><meta name="description" content="Multi-agent collaboration workspace for complex project delivery"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><link rel="icon" href="/icon.svg?3a6863548daca964" type="image/svg+xml" sizes="any"/><script>try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}</script><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="__variable_d8ebd1 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div class="flex h-screen w-screen items-center justify-center bg-[var(--background)]"><div class="text-sm text-[var(--muted-foreground)]">Loading...</div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-74f577c1b094f942.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[58321,[\"989\",\"static/chunks/989-cb287787a4aca288.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"ThemeProvider\"]\n3:I[24554,[\"989\",\"static/chunks/989-cb287787a4aca288.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[3124,[\"560\",\"static/chunks/1d2d5650-d8d130c85727fdbb.js\",\"87\",\"static/chunks/87-6a2716c98a819c4e.js\",\"989\",\"static/chunks/989-cb287787a4aca288.js\",\"974\",\"static/chunks/app/page-c9fb252d0cf34c20.js\"],\"default\"]\na:I[23632,[],\"OutletBoundary\"]\nc:I[86889,[],\"AsyncMetadataOutlet\"]\ne:I[23632,[],\"ViewportBoundary\"]\n10:I[23632,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[1935,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/7b736202f0b1dbfd.css\",\"style\"]\n:HL[\"/_next/static/css/e890aa0d13e94290.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"52ayDC9phpvb-95S7qHHq\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7b736202f0b1dbfd.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/e890aa0d13e94290.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"dark\",\"suppressHydrationWarning\":true,\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"script\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}\"}}]}],[\"$\",\"body\",null,{\"className\":\"__variable_d8ebd1 font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L6\",null,{\"Component\":\"$7\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@8\",\"$@9\"]}],[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/659eccb5db697b76.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"$La\",null,{\"children\":[\"$Lb\",[\"$\",\"$Lc\",null,{\"promise\":\"$@d\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Le\",null,{\"children\":\"$Lf\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$L10\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$11\",null,{\"fallback\":null,\"children\":\"$L12\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$13\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:{}\n9:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])</script><script>self.__next_f.push([1,"f:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nb:null\n"])</script><script>self.__next_f.push([1,"14:I[88809,[],\"IconMark\"]\nd:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Halo - Multi-Agent Workspace\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Multi-agent collaboration workspace for complex project delivery\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/icon.svg?3a6863548daca964\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}],[\"$\",\"$L14\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"12:\"$d:metadata\"\n"])</script></body></html>
|
package/admin-out/index.txt
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
4:I[63693,[],""]
|
|
5
5
|
5:I[74713,[],""]
|
|
6
6
|
6:I[5444,[],"ClientPageRoot"]
|
|
7
|
-
7:I[3124,["560","static/chunks/1d2d5650-d8d130c85727fdbb.js","87","static/chunks/87-6a2716c98a819c4e.js","989","static/chunks/989-cb287787a4aca288.js","974","static/chunks/app/page-
|
|
7
|
+
7:I[3124,["560","static/chunks/1d2d5650-d8d130c85727fdbb.js","87","static/chunks/87-6a2716c98a819c4e.js","989","static/chunks/989-cb287787a4aca288.js","974","static/chunks/app/page-c9fb252d0cf34c20.js"],"default"]
|
|
8
8
|
a:I[23632,[],"OutletBoundary"]
|
|
9
9
|
c:I[86889,[],"AsyncMetadataOutlet"]
|
|
10
10
|
e:I[23632,[],"ViewportBoundary"]
|
|
@@ -15,7 +15,7 @@ e:I[23632,[],"ViewportBoundary"]
|
|
|
15
15
|
:HL["/_next/static/css/7b736202f0b1dbfd.css","style"]
|
|
16
16
|
:HL["/_next/static/css/e890aa0d13e94290.css","style"]
|
|
17
17
|
:HL["/_next/static/css/659eccb5db697b76.css","style"]
|
|
18
|
-
0:{"P":null,"b":"
|
|
18
|
+
0:{"P":null,"b":"52ayDC9phpvb-95S7qHHq","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/7b736202f0b1dbfd.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/e890aa0d13e94290.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"dark","suppressHydrationWarning":true,"children":[["$","head",null,{"children":["$","script",null,{"dangerouslySetInnerHTML":{"__html":"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}"}}]}],["$","body",null,{"className":"__variable_d8ebd1 font-sans antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L6",null,{"Component":"$7","searchParams":{},"params":{},"promises":["$@8","$@9"]}],[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/659eccb5db697b76.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","$La",null,{"children":["$Lb",["$","$Lc",null,{"promise":"$@d"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Le",null,{"children":"$Lf"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$L10",null,{"children":["$","div",null,{"hidden":true,"children":["$","$11",null,{"fallback":null,"children":"$L12"}]}]}]]}],false]],"m":"$undefined","G":["$13",[]],"s":false,"S":true}
|
|
19
19
|
8:{}
|
|
20
20
|
9:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
|
21
21
|
f:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
|
@@ -69,6 +69,23 @@ Sessions live in the workspace's `.halo/` and A2A task state in
|
|
|
69
69
|
tokens, so it (and `-wal` / `-shm`) is hidden from non-full sessions
|
|
70
70
|
(`tools/sandbox.ts`).
|
|
71
71
|
|
|
72
|
+
Everything the mode keeps on the workspace (EFS), under `<ws>/.halo/`:
|
|
73
|
+
|
|
74
|
+
| File | What | Written |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `halo.db` (+ `-wal` / `-shm`) | session rows (title, counts, tokens, output, `reply_to`, `stopped_at`) | every turn and tool call — see [SQLite durability](#sqlite-durability) |
|
|
77
|
+
| `a2a.db` (+ `-wal` / `-shm`) | A2A tasks, push configs + outbox | task create / touch / transition, push delivery |
|
|
78
|
+
| `runs.db` (+ `-wal` / `-shm`) | run ledger, see [Resume after a mid-task kill](#resume-after-a-mid-task-kill) | entry / exit of every run |
|
|
79
|
+
| `sessions/<agentId>/<seg>.{ui,raw}.jsonl` | session logs ([session.md](session.md)) | appended at every save, rewritten whole on compaction / archive |
|
|
80
|
+
| `sessions/<agentId>/<seg>.ui.arch.<N>.<h>.jsonl.gz` | archived UI history | turn end, past 5 MB |
|
|
81
|
+
| `agentcore.lease` | single-writer lease (below) | heartbeat every 10 s |
|
|
82
|
+
| `runtime.lock` | runtime claim; deleted and re-claimed at each acquisition | lease acquisition |
|
|
83
|
+
| `agent-card.json` | the served card, seeded if missing | first non-`/ping` request |
|
|
84
|
+
| `agents/`, `skills/`, `prompts/`, `memory/`, `canvas/self.html`, `tmp/`, `assets/a2a/…` | workspace content, as on any server ([storage.md](storage.md)) | by the agent / inbound images |
|
|
85
|
+
|
|
86
|
+
The container's `HALO_HOME` (`~/.halo`: settings, model registry, `server.log`)
|
|
87
|
+
is the image's and dies with the microVM.
|
|
88
|
+
|
|
72
89
|
Each runtime session id gets its own microVM, all mounting the same EFS, so a
|
|
73
90
|
single-writer lease guards it — `<ws>/.halo/agentcore.lease`, plain NFSv4 file
|
|
74
91
|
ops only:
|
|
@@ -113,6 +130,16 @@ The [run ledger](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo
|
|
|
113
130
|
- `ownsRuntimes` stays `false`, so the lazy FAILED in `routes.ts` `reconcileStale` remains the fallback for a task the sweep didn't resume (root idle, quiet subtree): "Interrupted by a server restart on the remote. Send again on the same context to resume."
|
|
114
131
|
- Verified locally: `kill -9` mid-task (2 of 6 `sleep 30` calls done) → new process refused for ~45 s, then the first accepted GetTask read `WORKING` and the task completed with all six results.
|
|
115
132
|
|
|
133
|
+
### SQLite durability
|
|
134
|
+
|
|
135
|
+
**`synchronous=FULL` on the workspace dbs.** WAL mode defaults to `synchronous=NORMAL` (better-sqlite3 builds with `SQLITE_DEFAULT_WAL_SYNCHRONOUS=1`): a commit writes its WAL frames and returns without an fsync. On EFS those frames sit in the NFS client's page cache until writeback or a checkpoint — SQLite's unlock is on `-shm` and flushes nothing of `-wal`. A microVM that dies (stop timeout, crash, the 8 h `maxLifetime`) could lose its last seconds of commits: a lost `insertRunning` means the killed run is never resumed, a lost `createTask` leaves the caller a taskId nobody knows, a lost `transition` gets a completed task marked FAILED by the next boot's reconcile. So `createAgentCoreA2A` registers `<ws>/.halo` (`setDurableDbDir`, `db/durability.ts`) before anything opens there, and `createDb` / `createRunsDb` / `createA2ADb` set `synchronous=FULL` on a connection in that dir, right after `journal_mode` and before their own schema writes and the SessionManager constructor's sweeps. Server mode registers nothing — every db there (HOME-side ones included) keeps NORMAL. The session `.jsonl` files need nothing: every write closes its fd, and NFS close-to-open flushes on close.
|
|
136
|
+
|
|
137
|
+
Cost: one fsync per commit that changes a row; an UPDATE that changes nothing writes no frame and syncs nothing. Measured (strace, scripted A2A SendMessage): a follow-up turn with no tool call commits 10 times (a2a.db 2 — create + complete; runs.db 2 — insert + delete; halo.db 6 — `reply_to` set + clear, output / last-activity twice (first event, release), counts / tokens, `updated_at`); each tool call adds one (the next model call's token UPDATE), plus the output / last-activity write at most once per 5 s mid-turn. Checkpoints fsync the main db file on top.
|
|
138
|
+
|
|
139
|
+
**Known risk — `-shm` and dead-host locks on NFS (unverified).** SQLite doesn't support WAL on network filesystems; here the lease keeps a single writer, so normal running is fine, and the risk sits at a crash handoff. When a db opens, SQLite rebuilds the wal-index only if no other connection holds the DMS lock byte on `-shm`. A killed microVM's NFS locks persist until its lock lease expires on the server; if that outlasts the ~47 s lease handoff, the next holder keeps the old `-shm` contents — which the dead client's mmap may never have written back — and the wal-index may not match the WAL (wrong pages read). Needs measuring on EFS: the lock lease length, and a `kill -9` handoff under write load. If it bites, the candidate is `journal_mode=TRUNCATE` + `synchronous=FULL` on the workspace dbs (no `-shm`; ~8 ms per commit, ~5 ms more than now).
|
|
140
|
+
|
|
141
|
+
**Known cost — synchronous IO on the event loop.** Session-log writes, SQLite commits and the bwrap mask lookups are synchronous by design (they keep UI / raw writes and the tombstone check in one synchronous slice), so each EFS round trip blocks the loop: estimated at ~40 ms per tool call and ~70 ms per turn before the 2.0 IO trimming, during which other sessions and `/ping` wait. Moving to async IO would reorder the write paths' guarantees; not planned.
|
|
142
|
+
|
|
116
143
|
## Access model
|
|
117
144
|
|
|
118
145
|
"Upstream" strategy: AgentCore verifies the caller's SigV4 before forwarding,
|
|
@@ -143,7 +143,7 @@ UIState fields: `messageLog` / `streamBuffer` / `contextTokens` / `outputTokens`
|
|
|
143
143
|
|
|
144
144
|
### SessionUIStore — UI-log state + event routing
|
|
145
145
|
|
|
146
|
-
File: `agents/session-ui-store.ts`. The stateful host around UILogBuilder, split out of SessionManager. Owns the per-root-session UIState map, the debounced persist timers, and the event-listener registry; drives `applyEvent` and decides flush-vs-debounce. SessionManager constructs it with `this` as the `SessionUIStoreHost` (db / workspaceRoot / in-memory session lookup / delete tombstone / the single tombstone-honouring `
|
|
146
|
+
File: `agents/session-ui-store.ts`. The stateful host around UILogBuilder, split out of SessionManager. Owns the per-root-session UIState map, the debounced persist timers, and the event-listener registry; drives `applyEvent` and decides flush-vs-debounce. SessionManager constructs it with `this` as the `SessionUIStoreHost` (db / workspaceRoot / in-memory session lookup / delete tombstone / the single tombstone-honouring `persistUILog`) and exposes same-named thin pass-throughs so external callers stay unchanged. Dependency is one-directional: store → host, never the reverse.
|
|
147
147
|
|
|
148
148
|
### SessionQueryStore — read-only session-metadata queries
|
|
149
149
|
|
|
@@ -159,12 +159,12 @@ File: `agents/session-skill-commands.ts`. Stateless, pure reads. Resolves which
|
|
|
159
159
|
|
|
160
160
|
### SessionStateStore — rawMessages disk persistence
|
|
161
161
|
|
|
162
|
-
File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMessages` (LLM-facing history) to its
|
|
162
|
+
File: `agents/session-state-store.ts`. Stateless (the writer's append baseline lives in `session-log.ts`). Saves/loads an agent's `rawMessages` (LLM-facing history) to its `<seg>.raw.jsonl`, one message per line — a save appends the new messages, rewrites the last line in place when only it changed, and does an atomic full rewrite for anything else (compaction, repair, deleteExchange) — plus the row's `output` / `lastActivityAt` (throttled on the per-event save points). It is the raw half of session persistence; SessionUIStore owns the UI-log half in its own `<seg>.ui.jsonl`, so neither rewrites the other. `saveAgentState` takes a narrow `SavableSession` (5 fields), not the full AgentSession. Host surface: workspaceRoot + `isSessionDeleted` (the tombstone short-circuit so a late save can't resurrect a deleted file) + `metaDb` (the db holding the session's row).
|
|
163
163
|
|
|
164
164
|
## Helper modules
|
|
165
165
|
|
|
166
166
|
### SessionStore — disk persistence
|
|
167
|
-
File: `sessions/session-store.ts
|
|
167
|
+
File: `sessions/session-store.ts` (+ `session-log.ts` writers, `session-archive.ts`, `legacy-migrate.ts`). Path: `.halo/sessions/{agentId}/{seg}.ui.jsonl` + `{seg}.raw.jsonl`. Format described in [design/storage.md](storage.md#session-files-20).
|
|
168
168
|
|
|
169
169
|
### AgentLoader — agent config loader
|
|
170
170
|
File: `agents/agent-loader.ts`. `loadAgentYaml(agentId, wsRoot)` loads with workspace > global precedence.
|
|
@@ -250,7 +250,7 @@ SQLite only holds metadata indexes; all content lives on the filesystem.
|
|
|
250
250
|
| Medium | Writer | Contents |
|
|
251
251
|
|---|---|---|
|
|
252
252
|
| SQLite `agent_sessions` | SessionManager | Agent session metadata (root + children; includes `working_dir` and `access_level` columns) |
|
|
253
|
-
| `.halo/sessions/{agentId}/{
|
|
253
|
+
| `.halo/sessions/{agentId}/{seg}.{ui,raw}.jsonl` | SessionManager, SessionStore | Session messages |
|
|
254
254
|
| `.halo/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (workspace scope) |
|
|
255
255
|
| `.halo/agents/{id}/AGENT.md` | AgentConfigRoutes | Agent personality |
|
|
256
256
|
| `~/.halo/global/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (global scope) |
|
|
@@ -63,7 +63,7 @@ bindOrCreateSession → createSession(...) → subscribeSession(client, sm, sid)
|
|
|
63
63
|
Nothing is lost without a listener: `SessionUIStore.emitEvent` folds every event into the root's `UIState` (`reduceIntoUIState → applyEvent`) and persists the log it changed **before** it looks up listeners. Listeners are a pure fan-out for live UI; the persistence path is independent of them.
|
|
64
64
|
|
|
65
65
|
- **Which file**: `applyEvent` returns the log it touched. A root event writes the root's session file; a sub-agent event (`taskId` set) writes only that sub-session's **own** file from `subSessionLogs[taskId]` — the root's file isn't rewritten for sub-agent traffic. The sub log is dropped from memory at its `agent_done` (its file is complete by then).
|
|
66
|
-
- **When**: `applyEvent`'s `persist` hint — `'flush'` writes now (root `complete`, a sub's `agent_done`), `'debounce'` coalesces within 500 ms (
|
|
66
|
+
- **When**: `applyEvent`'s `persist` hint — `'flush'` writes now (`tool_call`, `tool_result`, `usage`, root `complete`, a sub's `agent_done`, `error`, `call_discarded`), `'debounce'` coalesces within 500 ms (`user` / `system` / `context` rows), absent = no write (the next write carries the change). Timers are keyed by the log's own id, so root and sub writes don't cancel each other.
|
|
67
67
|
- **Dirty tracking**: a root event marks the root `uiStateDirty` until `persistLog` lands it. `isUIStateDirty(rootId)` is what the WS saves below gate on — a state only seeded from disk (viewing a session another process, e.g. a cron `halo cli` child, is driving) is clean and is never written back, so it can't overwrite that process's newer file.
|
|
68
68
|
|
|
69
69
|
### When save fires
|
|
@@ -73,7 +73,7 @@ Background state persists in four scenarios:
|
|
|
73
73
|
1. **SessionUIStore's own persist** — the per-event rule above; this is what keeps a background session's files current
|
|
74
74
|
2. **`unsubscribeSession`** — a session leaves the connection's set (`unsubscribe`, or a workspace switch dropping every subscription); `saveSession` runs before the listener is released. It returns early unless `isUIStateDirty`
|
|
75
75
|
3. **Disconnect** — WS closes (or errors): every subscription without active work in its tree is saved and released (same dirty gate); the rest detach (below)
|
|
76
|
-
4. **Server shutdown** — `
|
|
76
|
+
4. **Server shutdown** — `landForShutdown()` (SessionManager). Its UI half, SessionUIStore `flushAll()`, lands every loaded tree: a dirty root plus every live sub log, since pending debounce timers would never fire. Its raw log + row half covers only the sessions this process is running (`promise !== null`, subs too) — an idle in-memory copy was saved on the spot and may be stale ([session.md](session.md#session-files))
|
|
77
77
|
|
|
78
78
|
Until 1.5.3-alpha, scenarios 2 and 3 went through `client.backgroundSaves`: the save closures `session:clear` registered, run on the next subscribe to that session or flushed on disconnect.
|
|
79
79
|
|
|
@@ -124,7 +124,7 @@ cleanupConnection
|
|
|
124
124
|
| `packages/server/src/ws/event-processor.ts` | `sendWsNotification()`, `bufferDetachedNotification()` |
|
|
125
125
|
| `packages/server/src/agents/session-ui-store.ts` | `emitEvent()`, `reduceIntoUIState()`, `persistLog()`, `flushPersist()` / `debouncedPersist()`, `isUIStateDirty()`, `flushSession()` / `flushAll()`, `registerEventListener()` |
|
|
126
126
|
| `packages/server/src/sessions/ui-log-builder.ts` | `applyEvent()`, `createSaveSnapshot()`, UIState type |
|
|
127
|
-
| `packages/server/src/sessions/session-store.ts` | `
|
|
127
|
+
| `packages/server/src/sessions/session-store.ts` | `saveUILog()`, `loadSessionMessages()` |
|
|
128
128
|
|
|
129
129
|
## Historical bug fixes (2026-04-20/21)
|
|
130
130
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Cron — Scheduled Tasks Design
|
|
2
2
|
|
|
3
|
-
Run an agent on a schedule (daily report, periodic broadcast) and fan the result out to channels. Jobs live in the global db
|
|
3
|
+
Run an agent on a schedule (daily report, periodic broadcast) and fan the result out to channels. Jobs live in the global db; [croner](https://github.com/hexagon/croner) only computes their fire times, the runner's own timers fire them, and each fire executes as a fresh `halo cli` child process.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Run an agent on a schedule (daily report, periodic broadcast) and fan the result
|
|
|
8
8
|
cron_jobs (global db) registered at boot:
|
|
9
9
|
│ ┌── slack/cron-dispatcher
|
|
10
10
|
▼ reloadAll / reconcileFromDb ├── feishu/cron-dispatcher
|
|
11
|
-
runner.ts ────
|
|
11
|
+
runner.ts ──── runner timers ────┐ ├── telegram/cron-dispatcher
|
|
12
12
|
│ │ ├── wechat/cron-dispatcher
|
|
13
13
|
│ │ └── wecom/cron-dispatcher
|
|
14
14
|
│ fire │ ▲
|
|
@@ -20,15 +20,17 @@ cron_jobs (global db) registered at boot:
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
Two halves, cleanly split:
|
|
23
|
-
- **runner.ts** — the scheduler. Owns the croner
|
|
23
|
+
- **runner.ts** — the scheduler. Owns the fire timers (croner only computes fire times), spawns the cli, persists audit rows.
|
|
24
24
|
- **dispatcher.ts** — a channel-agnostic registry. Knows how to look up a dispatcher by `channelType` and hand off; knows nothing about any specific channel.
|
|
25
25
|
|
|
26
26
|
## Scheduling model
|
|
27
27
|
|
|
28
|
-
Schedules are **durable** — the source of truth is the `cron_jobs` table; in-memory
|
|
28
|
+
Schedules are **durable** — the source of truth is the `cron_jobs` table; in-memory schedule state (the runner's timers + the paused, handler-less croner instances that only compute fire times) is rebuilt on every server boot (`startCronDaemon` → `reloadAll`). A restart never loses a schedule.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
The runner arms its own `setTimeout` toward each target in hops of at most 30 s; a hop that wakes early re-arms toward the **same** target, and the next target is computed (`nextRun(now)`) only after a fire — a sleep / clock jump over several fires runs once, no catch-up. Why not croner's own timers: in croner 10.0.1 a timer waking a fraction of a ms early re-plans from a fresh clock read, and when the clock crosses the fire instant mid-re-plan, an instance that has never fired (every job right after a restart, every one-shot) silently skips that fire — ~10% of first fires after a restart in production; the upstream fix is only in 11.0.0-dev (`test/cron-fire-race.test.ts`).
|
|
31
|
+
|
|
32
|
+
- **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. A schedule or timezone croner rejects is handled the same way: one `schedule invalid` warn per fingerprint (the timezone in it), retried once the row changes. REST validates the expression but not the timezone, and croner throws on a bad timezone only at `nextRun()`, so either can reach this from REST or an out-of-band write. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
|
|
33
|
+
- **One-shot**: `job.runAt` (epoch ms) — the runner's timer fires once at that instant (no croner). After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile) — unless its own run is still in flight (`_inflight`, e.g. `POST /api/cron/reload` mid-run): `finalize` then records the real status. At boot, the orphan sweep already finalized a one-shot whose run it found (`enabled=0`, `failed`), so the reload never sees it as missed. The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
|
|
32
34
|
- **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (each job keeps only its own newest 10 runs; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5). `cron_runs.workspace_path` (slot 5, v6) — the workspace the run fired against (the job's at fire time), i.e. where a stateless run's session lives even after the job moves workspace or is deleted; v6 backfills it from the job row, leaving NULL on rows whose job was already gone.
|
|
33
35
|
|
|
34
36
|
### Trigger-mode exclusivity (the db contract)
|
|
@@ -58,7 +60,7 @@ Each fire:
|
|
|
58
60
|
- Another job's cli already on the same session (`_inflightSessions`, keyed `realpath(workspace) + sessionId` so two spellings of one workspace collide) → `session <id> busy: another cron run is in progress`. Two jobs may pick the same session.
|
|
59
61
|
- The server itself is mid-turn anywhere in that session's tree (`hasActiveWorkInTree` on the workspace's live SessionManager, looked up with the registry's `peek` — cache-only, never builds a manager, whose constructor would run the boot reconcile as a side effect) → `session <id> busy: a turn is running in it`. In practice this is a picked session someone is chatting in.
|
|
60
62
|
|
|
61
|
-
Two overlapping writers would double-write the same on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so these sets are the cheapest place to enforce serialization. They are in-memory, but their restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds and session keys before
|
|
63
|
+
Two overlapping writers would double-write the same on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so these sets are the cheapest place to enforce serialization. They are in-memory, but their restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds and session keys before the runner re-arms its timers. The runner reaches the server's managers through `setCronSessionRegistry(registry)` (called in `index.ts` next to `setRelayRegistry`); unset — tests, a bare CLI — means there are no server-side sessions to guard.
|
|
62
64
|
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight), recording the session it runs in (`cron_runs.session_id`) and the job's current workspace (`cron_runs.workspace_path`; step 1's `skipped` row records the workspace too); broadcast `cron:run_changed`.
|
|
63
65
|
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted. Same for the job folder (`job.workingDir`, re-resolved with `resolveWorkingDir`): moved / deleted since the job was saved → `failed` with `folder not found: …`, nothing spawned.
|
|
64
66
|
4. Spawn `halo cli -a <agent> -s <session> -w <workspace> --dir=<folder|.> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. A **stateless** job (`cron_jobs.stateless`) skips all of this: every fire creates a fresh session (`perRunSessionId`), recorded on the run row.
|
|
@@ -84,14 +86,14 @@ The cli executable is resolved via `resolveHaloCli()` (`$HALO_CLI` override; `ha
|
|
|
84
86
|
|
|
85
87
|
### Orphan sweep on boot (`sweepOrphanRuns`)
|
|
86
88
|
|
|
87
|
-
The step-1 guard is per-process, so it has one blind spot: the server dies (SIGKILL, crash) while a cli child is mid-run. On POSIX the child is re-parented and survives; the restarted server has an empty `_inflight` and
|
|
89
|
+
The step-1 guard is per-process, so it has one blind spot: the server dies (SIGKILL, crash) while a cli child is mid-run. On POSIX the child is re-parented and survives; the restarted server has an empty `_inflight` and the runner re-arms the schedule — the next fire would double-write the orphan's session. Per the "persistent operations must not depend on in-memory state" rule, recovery is driven from the db: any `cron_runs` row still at `status='running'` at boot can only be a previous incarnation's leftover (this process hasn't run anything yet).
|
|
88
90
|
|
|
89
91
|
`startCronDaemon` calls `sweepOrphanRuns` **before** `reloadAll`. For each stale row:
|
|
90
92
|
|
|
91
93
|
1. Take the session the orphan was spawned on from the run row (`cron_runs.session_id` — the only source for a stateless job's per-run id; a row from before that column falls back to the job row's effective session, a job deleted since to the default rule on its id), then re-enter its jobId into `_inflight` and its session key into `_inflightSessions` first — the core invariant: while the orphan may be alive, no new cli for that job *or for any other job on that session* is spawned (a fire in that window is recorded as `skipped`, same as normal overlap).
|
|
92
94
|
2. If the row has a pid (POSIX): **identity-check before killing** — `ps -p <pid> -o args=` must carry the session id as a whole argv token (the `-s <session>` value; whole-token so `cron-a` never matches `cron-ab`), so a recycled pid never kills an innocent process. If the session came from the job row (pre-v5 run row) and was re-pointed after the orphan was spawned, the fingerprint won't match and the orphan is left alone — the safe direction. Verified → SIGTERM (graceful path, same as timeout phase 1); still alive after a 30s async grace (`unref`ed timer, identity re-checked — pid reuse again) → SIGKILL the descendant tree.
|
|
93
95
|
3. No pid / already dead / fingerprint mismatch / Windows: no kill, log only. Windows deliberately marks-only — there's no cheap command-line identity check (WMI is slow and sometimes policy-blocked), and a full-tree `taskkill /T /F` on a reused pid is irreversible while a double-written session is fixable; the orphan-survives-server-death window is also naturally smaller there.
|
|
94
|
-
4. Either way the row is finalized as `failed` (`failureReason` notes the orphan disposition, `completedAt` stamped) so the UI stops showing a ghost `running`, and the jobId / session key are released once the orphan is confirmed dead (or immediately when nothing was killable).
|
|
96
|
+
4. Either way the row is finalized as `failed` (`failureReason` notes the orphan disposition, `completedAt` stamped) so the UI stops showing a ghost `running` — for a one-shot its job row too, as `finalize` would (`enabled=0`, `lastRunStatus='failed'`, `lastRun*` = this run, `cron:job_changed` pushed); a recurring job's row is left alone — and the jobId / session key are released once the orphan is confirmed dead (or immediately when nothing was killable).
|
|
95
97
|
|
|
96
98
|
Only the kill grace is asynchronous; the boot-blocking part is one small select + row updates.
|
|
97
99
|
|
|
@@ -133,7 +135,7 @@ interface CronChannelDispatcher {
|
|
|
133
135
|
|
|
134
136
|
## Key files
|
|
135
137
|
|
|
136
|
-
- `packages/server/src/cron/runner.ts` — scheduler: croner
|
|
138
|
+
- `packages/server/src/cron/runner.ts` — scheduler: fire timers (croner computes times), `runJob` spawn/capture/finalize, reconcile, retention; `effectiveSessionId` / `perRunSessionId`, the read-only session lookup (`readSessionRow`) behind `sessionAgentIdFor` / `sessionAgentConflict`.
|
|
137
139
|
- `packages/server/src/cron/dispatcher.ts` — channel-agnostic dispatch registry (`registerCronDispatcher`, `dispatchToTargets`, `listAllCronTargets`).
|
|
138
140
|
- `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` schema, `CRON_MIGRATIONS`, `getCronDb`.
|
|
139
141
|
- `packages/server/src/agents/session-manager.ts` — `forgetExternalWrite` (drop the stale in-memory copy of a session a cli child just wrote).
|
|
@@ -18,18 +18,18 @@ Halo 服务器运行态的**街景式**像素可视化:每个 **workspace 是一
|
|
|
18
18
|
- 独立部署(不进 admin、走 token)要和 Halo 同源反代 `/api/show/`,nginx 写法见
|
|
19
19
|
hub [README.md](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/README.md#和-halo-同源)。
|
|
20
20
|
- `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
|
|
21
|
-
|
|
21
|
+
从会话的 `agent_sessions` 行读取(`context_tokens` / `total_output_tokens`),不显示 0;含 `messageCount` 字段
|
|
22
22
|
- `/api/show/state` 带弱 `ETag` + `Cache-Control: private, no-cache`:哈希不含
|
|
23
23
|
`serverTime`,`uptime` 按分钟计入,所以服务器空闲时 7 秒一次的轮询绝大多数回
|
|
24
24
|
空 body 的 304,每分钟最多一次 200。浏览器 `fetch` 默认缓存模式自动带
|
|
25
25
|
`If-None-Match`,扩展不用改;鉴权仍在最前,不同 scope 的调用方 ETag 不同
|
|
26
26
|
|
|
27
|
-
> **`messageCount`
|
|
28
|
-
>
|
|
29
|
-
>
|
|
30
|
-
>
|
|
31
|
-
>
|
|
32
|
-
>
|
|
27
|
+
> **`messageCount` 取自行的 `exchange_count`**(整个生命期的主用户轮次数,
|
|
28
|
+
> 归档后不缩;为 null 的 1.x 老行报 0),城市只把它当"这个市民最近有多热闹"
|
|
29
|
+
> 的活跃度信号。会话存储 2.0 起会话文件不再带文件头,元数据只在 db 行里,
|
|
30
|
+
> 只读降级路径照样只读 sqlite,不碰会话文件。降级路径的会话详情
|
|
31
|
+
> (`/api/show/session`)遇到还没迁移的 1.x 会话(没有 `ui.jsonl`)返回
|
|
32
|
+
> 404:经日志读取器打开它会触发迁移,是写操作,只读路径不能做。
|
|
33
33
|
|
|
34
34
|
## 轮询零副作用(服务端)
|
|
35
35
|
|