@turmind/halo 1.6.6 → 2.0.0
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/989-cb287787a4aca288.js +1 -0
- package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/design/agentcore.md +27 -0
- package/bundled-docs/design/architecture.md +3 -3
- package/bundled-docs/design/background-dispatch.md +2 -2
- package/bundled-docs/design/canvas-extensions.md +8 -0
- package/bundled-docs/design/cron.md +10 -8
- package/bundled-docs/design/halo-city.md +7 -7
- package/bundled-docs/design/session.md +95 -78
- package/bundled-docs/design/storage.md +64 -47
- package/bundled-docs/design/web.md +4 -0
- package/bundled-docs/design/ws.md +4 -3
- package/bundled-docs/dev/api.md +17 -13
- 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/secrets-and-credentials.md +17 -5
- 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/bundled-docs/requirements/settings.md +3 -0
- package/dist/index.js +3428 -1789
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/schema.sql +5 -1
- package/templates/skills/halo/SKILL.md +2 -0
- package/admin-out/_next/static/chunks/989-066a874961bd519f.js +0 -1
- package/admin-out/_next/static/chunks/app/page-812bcbb46db628b6.js +0 -1
- /package/admin-out/_next/static/{Yc05ORrVk5IE9P1Gd8Ojh → snq8MT7CRlizWQcyu-Kt4}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{Yc05ORrVk5IE9P1Gd8Ojh → snq8MT7CRlizWQcyu-Kt4}/_ssgManifest.js +0 -0
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--snq8MT7CRlizWQcyu_Kt4--><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\":\"snq8MT7CRlizWQcyu-Kt4\",\"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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
1:"$Sreact.fragment"
|
|
2
|
-
2:I[58321,["989","static/chunks/989-
|
|
3
|
-
3:I[24554,["989","static/chunks/989-
|
|
2
|
+
2:I[58321,["989","static/chunks/989-cb287787a4aca288.js","177","static/chunks/app/layout-ea161287d934ba96.js"],"ThemeProvider"]
|
|
3
|
+
3:I[24554,["989","static/chunks/989-cb287787a4aca288.js","177","static/chunks/app/layout-ea161287d934ba96.js"],"AppI18nProvider"]
|
|
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-
|
|
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":"snq8MT7CRlizWQcyu-Kt4","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
|
|
|
@@ -164,7 +164,7 @@ File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMes
|
|
|
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) |
|
|
@@ -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 is SessionUIStore `flushAll()`) lands every loaded tree: a dirty root plus every live sub log, since pending debounce timers would never fire
|
|
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
|
|
|
@@ -279,6 +279,14 @@ An extension can ship agent skills next to its viewer: `~/.halo/global/extension
|
|
|
279
279
|
- **Read from disk**: the server, CLI / TUI and cron children all scan the extensions root on every read, never the in-memory registry, so every process sees the same set. In the admin, the `extension:changed` frame also bumps the skill bus, so the Skills sidebar and the agent form's skill picker refetch. Uninstalling removes the skills at once (next read finds no folder, no restart). The watcher is non-recursive, so editing files inside an installed extension's `skills/` pushes nothing. Reinstalling the extension is the way to publish a change.
|
|
280
280
|
- **Sandbox**: `~/.halo/global/extensions/**` is outside the sandbox hidden lists, so workspace- and readonly-access sessions can read the skill's resources, and a workspace-access `shell_exec` can run its scripts. `activate_skill` names the skill's absolute directory in its resources header, so the agent knows where they are.
|
|
281
281
|
|
|
282
|
+
### Extension docs
|
|
283
|
+
|
|
284
|
+
An extension documents itself; nothing it ships is injected into prompts or INDEX (per-turn tokens, and INDEX has a 12K-character budget).
|
|
285
|
+
|
|
286
|
+
- **Agent-facing instructions** → an extension skill (above). Opt-in per agent like any skill; an install never enables one by itself.
|
|
287
|
+
- **Human-facing docs** → `README.md` at the extension root (every halo-hub extension has one), plus `DESIGN.md` for internals. They ship in the zip and sit at `~/.halo/global/extensions/<id>/` once installed.
|
|
288
|
+
- **How agents find them** → the built-in `halo` skill (Platform Documentation, since `TEMPLATE_VERSION` 104) tells the agent to `file_read` that README when asked about an installed extension. The core repo's docs cover only what the core implements (protocol, capabilities, server proxies), not a hub extension's behavior.
|
|
289
|
+
|
|
282
290
|
## `extension` skill and halo-hub
|
|
283
291
|
|
|
284
292
|
Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_VERSION` bumped to 69 in the same change): object command `/extension install|list|remove|models` (`models` = model provider configs, see the end of this section), `requiresAccess: full`. The built-in `default` agent lists it in `skills:` (since `TEMPLATE_VERSION` 81), so `/extension` shows in the command popup wherever the default agent is the built-in one. The payload reaches the body as `$ARGUMENTS`; when it names a source the agent installs right away without confirming, and it mentions the hub only when asked what exists or when an install fails. Everything goes through `templates/ext.sh` (curl + unzip + node, + git for tag-based hubs; no `gh`). `ext.sh install <id|zip|url>`: a zip path / URL is used as given, an id is fetched from the hub (below); then the same pre-check + six-step atomic replace as the server. It never talks to the server — the watcher picks the rename up within ~300 ms. The `halo` skill has a one-line pointer.
|
|
@@ -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
|
+
|
|
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`).
|
|
29
31
|
|
|
30
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. 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).
|
|
31
|
-
- **One-shot**: `job.runAt` (epoch ms) —
|
|
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). 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,7 +86,7 @@ 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
|
|
|
@@ -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
|
|