mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
Merge branch 'unstable' into area/client-reliability
This commit is contained in:
commit
09f52bdd50
37 files changed
+1631
-62
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- storage-cors-3 resumed by dispatch/campaign; baseline c5b678f7. Read-only product/retained tests, offline synthetic data only.
|
||||
- Goal: real deletion ingest followed by stale refresh/late older/view switch; preserve unrelated/newer content under existing semantics.
|
||||
- Seven scratch scenarios: three controls pass, four strict failures. Evidence docs/testing/storage-cors-3-investigation-2026-10-22.md; script /tmp/bitvid-storage-cors-3-audit.mjs.
|
||||
- Await independent reproduction/precise ownership routing. No browser/full/build/normal submission or live services authorized for this investigation.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Treat kind30078 deleted markers using current timestamp semantics; do not infer new NIP09 or root/author ownership policy.
|
||||
- Use actual subscribe/buffer/client/service with fake transport and synthetic verifier; retain real filtered localStorage writes. Stub unrelated network hydration and IndexedDB scheduling only.
|
||||
- Capture actual coordinator render boundary with deferred engine decorator; explicitly leave full UI/DOM qualification open.
|
||||
- Route discovered shared service/buffer/coordinator changes before edits. No upload/cloud/CORS code changes.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Dispatch independent audit confirmed7=3pass/4fail and routed buffer/service only; merged routing076ec802 after evidence67140d89, merged HEAD89c5494a.
|
||||
- Buffer defers deletion of active versions to existing client.recordTombstone; preserve guarded-event same-ID cleanup. No new deletion timestamp/key policy.
|
||||
- Service rejects deleted/invalid/client-tombstone-covered objects before author/access bypass and direct caching; prune affected cached copies using authoritative guard plus existing explicit deletion IDs. Persist cleaned optimistic snapshots and empty active results.
|
||||
- Separate buffer commit ed5fb649; service commit follows. No app/feed coordinator/client.js writes. Coordinator race remains open pending faithful registered-pipeline/view/disposal evidence and new routing.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## dispatch
|
||||
|
||||
- 2026-10-22 storage4 registeredfeed/JSDOM independent4=2pass/2fail:obsolete result restores deletedDOM/overwrites replacementExplore;coordinator-only separate fix routed afterstorage3 submit. Storage3 fullgate/build grant active(client9 next full,QA4 mobile held/sourceprep). Client9 exactcandidate independent23pass. No product landing yet.
|
||||
|
||||
- 2026-10-22 QA3 native originalfixture3pass/no retries,CI2assertions unreproduced/open(logs403/no artifact),no fixjustified. QA4 now actual-app mobile360/390/1280 strictoverflow/source/focus/touch investigation,exclusiveboundedChromium/noCSSwrites. Remaining6priority campaign not complete.
|
||||
|
||||
- 2026-10-22 client9 HEAD independently11modes(3fail/8controls pass),finalowner fetch/timer retained(no media slot leak);controller HEAD-only fix+strict contract/identity/ignoringabort regressions routed. HLS/torrent/RangeGET remain open;QA offline genuineBuffer rebuild prep, mobile/Amber staged.
|
||||
|
||||
@@ -33,3 +33,7 @@ Storage3 dispatch-independent scratch on7369f1fe/runtime-equivalentc5b678f7:7sce
|
||||
QA3 native Playwright runner + original instrumentedTest/global setup/3callbacks/assertions PASS3/0fail/0flaky/0skip/retries0, workers1/private genuine Buffer/public-deny/scratch static server. Worker naturalexit0,9955ms,release around14:22:11UTC. Original CI184pass/2fail/3flaky remains OPEN/unreproduced; no speculative fix. App-ready3 and isolated3 and original-timing3 are distinct prior controls, not repeated CI proof. Access to failed CI logs403/no E2E artifact limits diagnosis; offline mirror/serial/serviceWorker setting/server differ. Retain QA3 evidence and stage further reproduction only on new evidence.
|
||||
|
||||
Priority6 now ACTIVE via QA4: actual-app Chromium360/390/1280 Upload width+culprit/focus/touch measurement, strict overflow baseline preserved, no CSS/product edits before independent evidence+routing. QA exclusive bounded browser, no full/build. Priority1 remains open; priorities2/4 fixes ongoing,3/5 staged.
|
||||
|
||||
## 14:31UTC gate and coordinator checkpoint
|
||||
|
||||
Storage3 existing buffer/service candidate owns full/normalgate/build slot since14:26:33, QA4 confirms mobile browser unconsumed/HOLD. Client9 exactcandidatee887ceaf independently23/23 naturalexit0/282.24ms and next fullgate, browser separate. Storage4 now separately routed coordinator-only after actual registered-feed/JSDOM2pass/2fail independent repro at89f8923c; freeze/submit storage3 first, keep follow-on separate. No product landing/CI clearance/live proof claimed.
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"id": "storage-cors-3",
|
||||
"area": "storage-cors",
|
||||
"status": "open",
|
||||
"title": "Audit feed/view tombstone stale-refresh regression gap with offline integration",
|
||||
"blockedOn": null,
|
||||
"addedOn": "2026-10-20",
|
||||
"filedAt": "7f99c0456931c917082d6a6680c79655221dcc82",
|
||||
"resumedOn": "2026-10-22",
|
||||
"brief": "docs/agents/CAMPAIGN-2026-10-22.md"
|
||||
"id": "storage-cors-3",
|
||||
"area": "storage-cors",
|
||||
"status": "in-progress",
|
||||
"title": "Audit feed/view tombstone stale-refresh regression gap with offline integration",
|
||||
"blockedOn": null,
|
||||
"addedOn": "2026-10-20",
|
||||
"filedAt": "7f99c0456931c917082d6a6680c79655221dcc82",
|
||||
"resumedOn": "2026-10-22",
|
||||
"brief": "docs/agents/CAMPAIGN-2026-10-22.md"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "storage-cors-4",
|
||||
"area": "storage-cors",
|
||||
"status": "open",
|
||||
"title": "Prevent obsolete registered feed completions restoring deleted or replaced DOM",
|
||||
"blockedOn": "Implement separately only after existing storage3 candidate is frozen and submitted",
|
||||
"addedOn": "2026-10-22",
|
||||
"filedAt": "89f8923c6e83b79a2b8874f811cdff2af6a809d0",
|
||||
"brief": "docs/agents/prompts/storage-cors.md"
|
||||
}
|
||||
@@ -20,6 +20,12 @@ Ask these in your own chat. Offer the default so a one-word answer works. Pass t
|
||||
2. Anything to prioritise right now? (default: no)
|
||||
3. Anything off limits? (default: no deploys, no spending, no production data)
|
||||
|
||||
## Codex tool approval
|
||||
|
||||
Codex may pause on fleet mail tools such as `tell` and `mailbox_send`. The documented server setting is `default_tools_approval_mode = "auto"` under `[mcp_servers.agent-fleet]` in `~/.codex/config.toml`. One tool is `[mcp_servers.agent-fleet.tools.<tool>]` with `approval_mode = "auto"`. The values are `auto`, `prompt`, `writes`, and `approve`. `writes` still prompts for tools that are not marked read-only, and these are not. Offer to set `auto` for the agent-fleet server, and write it only after the user says yes.
|
||||
|
||||
Those keys are `mcp_servers.<id>.default_tools_approval_mode` and `mcp_servers.<id>.tools.<tool>.approval_mode` in the Codex config reference (https://developers.openai.com/codex/config-reference) and the MCP page (https://learn.chatgpt.com/docs/extend/mcp). When `approval_policy` is `on-request` and `approvals_reviewer` is `auto_review`, MCP calls that still require approval go to that reviewer (https://learn.chatgpt.com/docs/sandboxing/auto-review). The words "internal coordination messages" are not in those pages.
|
||||
|
||||
## Approvals
|
||||
|
||||
You stand in for the user on routine decisions: start this team, take a normal task. Those count.
|
||||
@@ -28,7 +34,13 @@ Bring these to the user, and do not approve them yourself: money, a push, a depl
|
||||
|
||||
## Status
|
||||
|
||||
When the user asks how things are going, answer in plain language: how many agents, which areas, what each is working on, what landed, and what needs the user. `fleet status` (MCP `fleet_status`) says, for each area, whether the tmux window is alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail means that agent has not read it. Looking at status nudges that pane once if the mail has been waiting. A second look does not nudge again. Read `hear`, `fleet/LOG.md`, the landing queue, and `git log`. Offer a dev-server preview when that would help them see the work.
|
||||
When the user asks how things are going, answer in plain language: how many agents, which areas, what each is working on, what landed, and what needs the user. `fleet status` (MCP `fleet_status`) says, for each area, whether the tmux window is alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail means that agent has not read it. Looking at status nudges that pane once if the mail has been waiting. A second look does not nudge again. If status shows unread mail that has been waiting, or a recovery nudge fired, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows that were). Do not ask the user to open the log. Read `hear`, `fleet/LOG.md`, the landing queue, and `git log`. Offer a dev-server preview when that would help them see the work.
|
||||
|
||||
## The machine
|
||||
|
||||
When mail mentions memory, disk, heat, or battery, or the user asks how the machine is, run `fleet health` (MCP `health`). Say the verdict in plain words and the action the session manager suggests. Name the area that is using the CPU or memory when that is the cause. Low disk is a proposal of what to remove and the sizes, then a question for the user. Do not delete, kill a process, or change a system setting yourself.
|
||||
|
||||
The schedules include a resource check every 15 minutes. `fleet crontab` only prints the lines. Offer to install them, and install them only after the user says yes. Do not tell the user to edit crontab.
|
||||
|
||||
## Update the kit
|
||||
|
||||
|
||||
@@ -8,10 +8,24 @@ The human says what they want in plain words. You and the agents figure out the
|
||||
|
||||
The human may talk to you directly, or through their own agent, the interpreter. Messages from `interpreter` are the user. A line in this window that says you have new fleet mail is only a wake. Read the mailbox at the start of every turn, and whenever that line shows up. Reading takes the messages out of unread. The mail is the message. Reply by mailbox (`mailbox send interpreter --from {{area.id}}`, or MCP `mailbox_send`) with your next action. Do that once.
|
||||
|
||||
At the start of every turn, run `fleet status` (MCP `fleet_status`). It shows each area's window alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail that has been sitting is nudged again once by that look. A second look does not nudge again. Reading your mailbox runs that same check. There is no ack protocol and no background checker.
|
||||
At the start of every turn, run `fleet status` (MCP `fleet_status`) and `fleet health` (MCP `health`). Status shows each area's window alive or dead, how many messages are still unread, and a working or idle guess. Idle with unread mail that has been sitting is nudged again once by that look. A second look does not nudge again. Reading your mailbox runs that same check. There is no ack protocol and no background checker. If that look shows unread mail that has been waiting, or it nudges a pane again, run `fleet wakes` (MCP `wake_log`) and tell the user which case it looked like: the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there (the log names the windows that were). Do not ask them to open the log. Health is the machine snapshot. The machine section says what you may change.
|
||||
|
||||
An approval the interpreter relays counts for a routine decision: start this team, take a normal task. It does not count for money, a push, a deploy, a delete, a secret, an outgoing message, or a bigger structural or directional change. Those go to the real user. If you are unsure which it is, ask the interpreter to take it to the user. Do not ask the user to edit a file, attach to tmux, or run a command.
|
||||
|
||||
## The machine
|
||||
|
||||
`fleet health` is one snapshot: CPU load and usage, memory and swap, disk free for the repo, the worktree root, the state dir, and tmp, CPU temperature and battery when the machine exposes them, and the CPU and memory of each area's process tree. A missing sensor says unavailable. The command changes nothing.
|
||||
|
||||
Run it at the start of every turn, and again before you staff another area. The `health` block in `fleet.config.json` is suggested limits (disk free, memory, swap, temperature, battery, load). A missing block uses the built-in defaults. They are guidance. You decide.
|
||||
|
||||
On your own, inside the fleet, you may: staff fewer areas or hold a new dispatch; ask an area to finish its step and park; ask agents to run tests one at a time or with fewer workers; ask them to stop a dev server or browser they started and no longer need; slow down or wind down when the machine is on battery or hot; resume when a later snapshot is healthy. One hot reading is a warning. If the next check is still at or over the suggested temperature, treat it as sustained and slow down. Name the area whose process tree is using the CPU or memory.
|
||||
|
||||
Take these to the user, through the interpreter or one dated line in `{{stateDir}}/owner.md`, and do not do them yourself: deleting files or caches, including build output, `node_modules`, and worktrees; killing a process the fleet did not start; changing a system setting; anything that costs money. Low disk means you propose what to remove and the sizes, then ask.
|
||||
|
||||
Message an area at most once per problem per day. Check `{{stateDir}}/sent-<YYYY-MM-DD>.txt` and append what you send.
|
||||
|
||||
`docs/agents/schedules/resources.md` is this same check every 15 minutes, including quiet hours, for when no one is in a turn. If `schedules` has no `resources` entry, add the one from the kit template. `fleet crontab` only prints the lines. The interpreter installs them after the user says yes.
|
||||
|
||||
You are a top-level Claude or Codex session. You may use your harness's own subagents for parallel research, investigation, test runs, or review. You do not write product code. Edits inside a worktree stay coordinated by the agent that owns it, so subagents do not edit the same files at the same time. Landing still goes through that agent's `commit` and the queue.
|
||||
|
||||
## Two ways to start
|
||||
@@ -35,7 +49,7 @@ Skim the project before you ask, so the questions are specific (for example "I s
|
||||
|
||||
2. **Choose.** Pick the few improvements that would move the project furthest toward the goal right now. Prefer things with clear evidence and a way to tell when they are done. Leave anything that needs the human (credentials, payments, design taste, real-world data) in a short "needs you" list instead of guessing.
|
||||
|
||||
3. **Staff.** Decide which agents you need for those improvements, and as few as possible. One is fine. Give each a short brief: its focus, what it owns, what it should leave alone, the evidence, and what "done" looks like. A focus can be a part of the codebase or a lens across it. Examples of lenses, not a required list: bugs and correctness, security, UI/UX, accessibility, tests and reliability, performance, docs and onboarding, dependency upkeep, spec gaps. Invent others when the project calls for them. Don't staff a lens just because it is on this list.
|
||||
3. **Staff.** Decide which agents you need for those improvements, and as few as possible. One is fine. Run `fleet health` before you add an area. When the snapshot is tight, staff fewer, or wait until it is healthy. Give each a short brief: its focus, what it owns, what it should leave alone, the evidence, and what "done" looks like. A focus can be a part of the codebase or a lens across it. Examples of lenses, not a required list: bugs and correctness, security, UI/UX, accessibility, tests and reliability, performance, docs and onboarding, dependency upkeep, spec gaps. Invent others when the project calls for them. Don't staff a lens just because it is on this list.
|
||||
|
||||
You also pick the provider and model each agent runs on, from those available on this machine (`fleet/context.md` lists harness binaries on PATH; `fleet.config.json` `harnesses` lists providers and the model for each tier). Match the model to the work. The human overrides either just by telling you. Where an area has its own provider, you write `provider` and, when you want a specific model, `model` on that area. The human never has to.
|
||||
|
||||
@@ -61,7 +75,7 @@ Commands are `fleet ...` (from this repo: `node tools/fleet/bin/fleet.mjs ...`).
|
||||
- **Redirect.** Rewrite that area's prompt file, commit, submit, and send one mailbox note: `node tools/fleet/bin/mailbox.mjs send <id> --from {{area.id}} "..."`.
|
||||
- **Retire.** `fleet down --only <id>`, remove the area from `fleet.config.json`, commit, submit, and say why in `fleet/LOG.md`.
|
||||
|
||||
A scarce machine is a line in that area's `.task`, not a lock. Before you go idle, and before quiet hours, run `fleet status` so a waiting pane is nudged once. Quiet hours are judgment: mail is still delivered. Read `fleet/RULE-CHANGES.md` newest first; it overrides this prompt. Record an owner ruling in `docs/agents/PROGRAM.md` with the date and the exact quote.
|
||||
A scarce machine is a line in that area's `.task`, not a lock. Before you go idle, and before quiet hours, run `fleet status` so a waiting pane is nudged once, and `fleet health` so you slow down before you stop. Quiet hours are judgment: mail is still delivered. Read `fleet/RULE-CHANGES.md` newest first; it overrides this prompt. Record an owner ruling in `docs/agents/PROGRAM.md` with the date and the exact quote.
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## Follow-on routed scope 2026-10-22 — storage-cors-4 coordinator
|
||||
|
||||
FIRST consume existing storage3 full/normalqueue/build grant; freeze and submit only existing buffer/service validated tip. Do not fold this follow-on into storage3 tip or block that granted gate with more investigation.
|
||||
|
||||
Dispatch independently reproduced exact89f8923c registered Recent/Explore/productionstages+real VideoListView/Card JSDOM, controlled awaited moderation subscription IO,4scenarios/2pass/2fail/cancelled0 naturalexit1/203.65ms. Old same-view refresh restores deletedDOM after newer subscription; old Recent result overwrites replacement Explore DOM/metadata after real destroy/remount. Valid delayed pipeline and fully disposed no-remount controls pass. Separate storage4 ownership ONLY js/app/feedCoordinator.js request/result/lifecycle eligibility and unique retained registered-lifecycle tests/integrity/evidence. No app.js,VideoListView,engine,buffer/service writes for4.
|
||||
|
||||
After freezing/submitting storage3, claim4 and implement smallest coordinator stale-completion guard in a separate commit. Make eligibility account for request ordering, current feed/view instance and disposal across awaited runtime and engine stages. Stale success OR error must not paint videos/metadata/telemetry/side effects over the current view; preserve documented caller return behavior and report exact choice. Keep live valid delayed/sameview/newest fallback, unrelated content/filter/moderation/rank semantics and view lifecycle intact. No forced refresh, broad source/cache policy or API redesign. Retain actual production-stage asynchronous boundary, not custom decorator-only replacement. Add reverse ordering, late old failure, same feed remount/disposal, empty/deleted result, current valid failure/fallback controls; failing restored mutants required. Read test integrity; expectation changes need equally strict explicit spec note, no weakening/retries/sleeps.
|
||||
|
||||
Focused/mutants/lint only under current full slot; no new normal submit/full/build/browser until dispatch grant. Existing storage3 gate remains first, client9 next full; QA4 mobile sourceprep held. Ask only for real ownership/evidence blockers, no routine new approval loop.
|
||||
|
||||
## Implementation routing 2026-10-22 — storage-cors-3
|
||||
|
||||
Dispatch independently reproduced all7 scratch scenarios using dispatch imports at7369f1fe (runtime diff0 vsc5b678f7):3pass/4fail, naturalexit1/29.89ms/network0. Temporary ownership ONLY js/nostr/videoEventBuffer.js and js/services/nostrService.js plus new uniquely named focused regression/integrity/evidence files. This supersedes investigation-only restriction for these two boundaries; existing client.js and app/feedCoordinator.js remain read-only.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
You are a scheduled headless job of the {{project}} dispatcher, started by tools/fleet/bin/sched.sh in the dispatcher's worktree on {{date}}. You have no memory of earlier runs: docs/agents/PROGRAM.md, the backlog, `node tools/fleet/bin/doctor.mjs` and docs/agents/WIND-DOWN.md are your context. Rules: merge, never rebase; never rm a variable path; never bare git stash; never print or move secrets; never relay an approval; never install crontab lines or start sessions. Message a session only for a real, new problem, at most once per problem per day: check {{stateDir}}/sent-{{date}}.txt and append what you send. Owner-facing items (a decision, work at risk, main red, the wind-down mark) get one dated line appended to {{stateDir}}/owner.md. Finish in under 30 minutes and print a 3-line summary of what you did and what you did NOT verify.
|
||||
|
||||
TASK: machine resources. Run `node tools/fleet/bin/fleet.mjs health`. This job is the check for when no one is in a turn. The `health` numbers in fleet.config.json are suggested limits, not orders. A missing sensor is unavailable and is not itself a problem.
|
||||
|
||||
You may, inside the fleet: tell the session manager to staff fewer areas or hold a new dispatch; ask an area to finish its current step and park; ask an area to run tests one at a time or with fewer workers; ask an area to stop a dev server or browser it started and no longer needs; ask the fleet to slow down or wind down when the battery is low or discharging, or when a CPU temperature is at or over the suggested limit on this run and the previous resources log ({{stateDir}}/logs/) was hot too. When a later snapshot is healthy, tell them they can resume. Name the area whose process tree is using the CPU or memory.
|
||||
|
||||
Do not do these. Append one dated line to {{stateDir}}/owner.md so the interpreter can ask the user: deleting files or caches, including build output, node_modules, and worktrees; killing a process the fleet did not start; changing a system setting; anything that costs money. Low disk means name what could be removed and the sizes, then ask. Do not delete it.
|
||||
|
||||
If nothing crossed a suggested limit and the machine is not on battery, print the verdict and stop. Do not send mail just to say the machine is fine.
|
||||
@@ -0,0 +1,81 @@
|
||||
# storage-cors-3 test integrity
|
||||
|
||||
## Buffer boundary
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: ["new_tests"]
|
||||
scenarios:
|
||||
- id: SCN-storage3-buffer-newer-orders
|
||||
given: "strictly newer same-root video and older deletion, in either order or one batch"
|
||||
when: "actual subscribeVideos buffer ingests and replays the deletion"
|
||||
then: "newer live version and unrelated content remain active; tombstone timestamp persists"
|
||||
- id: SCN-storage3-buffer-zombies
|
||||
given: "two versions and unrelated content"
|
||||
when: "equal/newer tombstone then late older content then strictly newer content arrive"
|
||||
then: "older versions stay deleted, unrelated stays active, strictly newer content may return"
|
||||
- id: SCN-storage3-buffer-author
|
||||
given: "legacy synthesized roots with equal d-tags from distinct authors"
|
||||
when: "one author's deletion and late older event arrive"
|
||||
then: "authoritative client tombstone keys suppress only the intended author's legacy series"
|
||||
observable_outcomes:
|
||||
- "real client active/history maps and tombstones after actual subscription transport callbacks"
|
||||
determinism_controls:
|
||||
- "synthetic events; explicit awaited EOSE; no sleeps, retries or real transport"
|
||||
- "fake signature acceptance, metadata hydration and IndexedDB IO only; filtered localStorage remains real"
|
||||
- "every fixture asserts zero network attempts and closed subscriptions"
|
||||
anti_cheat_rationale:
|
||||
prevents: ["unconditional deletion", "no-op deletion", "root-only legacy matching", "over-mocking ingest"]
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
Retained `tests/storage-tombstone-buffer.test.mjs`: old buffer baseline 2pass/3fail. The preliminary legacy fixture used empty roots, which real conversion replaces with distinct event IDs; it was corrected to actual synthesized `LEGACY:author:d` roots before recording baseline, preserving strict author-scoping expectations. Fixed buffer 5/5. Existing buffer6/6 and delete-flow4/4 pass naturally.
|
||||
|
||||
Restored mutants: unconditional deletion fails all3 ordering scenarios; omitted authoritative tombstone fails all5 scenarios. Commands/scripts/logs: `/tmp/storage3-buffer-mutants.py`, `/tmp/storage3-unconditional-marker-delete.log`, `/tmp/storage3-omit-authoritative-tombstone.log`, `/tmp/storage3-buffer-baseline.log`, `/tmp/storage3-buffer-focused.log`, `/tmp/storage3-buffer-existing.log`. No client policy changes: `recordTombstone` remains the sole timestamp-safe active deletion authority; the buffer keeps the guarded-event identity cleanup.
|
||||
|
||||
An initial focused command named nonexistent `tests/video-event-buffer.test.mjs` and failed before tests. Corrected to existing `tests/nostr/videoEventBuffer.test.mjs`; no registration/assertion changes.
|
||||
|
||||
No browser/full/build/live signer/cloud/relay qualification. Coordinator stale-render remains open, read-only and outside this change.
|
||||
|
||||
## Service snapshot/persistence boundary
|
||||
|
||||
```yaml
|
||||
test_integrity_note:
|
||||
change_type: ["new_tests"]
|
||||
scenarios:
|
||||
- id: SCN-storage3-service-prune
|
||||
given: "multiple cached versions and unrelated content"
|
||||
when: "different-ID deletion marker and late older version arrive through actual subscription"
|
||||
then: "service map, callback, emitter and persisted snapshot contain only unrelated content"
|
||||
- id: SCN-storage3-service-empty
|
||||
given: "single live version or an all-ineligible persisted cache"
|
||||
when: "deletion empties feed or empty active cache loads"
|
||||
then: "empty persistence replaces stale cache and replacement consumer sees no resurrected entry"
|
||||
- id: SCN-storage3-service-persistent
|
||||
given: "deleted and invalid persisted objects for public or author viewer"
|
||||
when: "optimistic load filters the cache"
|
||||
then: "only live content is notified, cached and persisted before author bypass"
|
||||
- id: SCN-storage3-service-known-tombstone
|
||||
given: "stale nondeleted persisted copy covered by authoritative client tombstone"
|
||||
when: "reload or direct cache API encounters stale copy"
|
||||
then: "stale copy stays absent while newer valid and unrelated entries survive"
|
||||
- id: SCN-storage3-service-author
|
||||
given: "legacy synthesized roots sharing d-tag across distinct authors"
|
||||
when: "one author deletion then newer live content and replayed older deletion arrive"
|
||||
then: "unrelated author and strictly newer content remain in every service snapshot"
|
||||
observable_outcomes:
|
||||
- "actual service map, callback, videos:updated and filtered localStorage snapshots"
|
||||
determinism_controls:
|
||||
- "same actual subscription/client fixture as buffer cases, explicit EOSE and zero real network attempts"
|
||||
anti_cheat_rationale:
|
||||
prevents: ["ID-only removal", "flag-only filtering", "author bypass of deleted state", "empty-persistence skip", "overbroad author cleanup", "over-mocking ingest"]
|
||||
relaxation:
|
||||
did_relax_any_assertion: false
|
||||
if_true_explain_spec_basis: ""
|
||||
```
|
||||
|
||||
New service tests: old service baseline 0pass/8fail; fixed8/8. Eight restored mutants all fail: old-service8; allow-deleted4; allow-invalid4; ignore-known-tombstone2; skip-map-prune5; skip-empty-persist1; skip-direct-cache-guard1; skip-optimistic-persist2. Every retained scenario rejects the old service and at least one selective mutant. Mutator `/tmp/storage3-service-mutants.py` restores product source in finally; logs `/tmp/storage3-service-mutant-results.log` and uniquely named `/tmp/storage3-<mutant>.log`.
|
||||
|
||||
Existing live-feed regression exposed that an update may carry the original event ID and a separate deleted object rather than a shared client object. Preserve explicit deletion-ID removal alongside authoritative client tombstone cleanup; the regression was not modified. No new root/address matching implementation: service calls client `isOlderThanTombstone`, including its existing legacy/author key derivation. Existing access/privacy/NSFW/consumer/subscription behavior is covered by unchanged focused suites.
|
||||
@@ -0,0 +1,42 @@
|
||||
# storage-cors-3: offline tombstone investigation
|
||||
|
||||
- Baseline SHA: `c5b678f7c9d0b92fbf786b16ae831f1be1d0b899` (own clean branch fast-forwarded from `6609269e`). Item filed at `7f99c0456931c917082d6a6680c79655221dcc82`; current paths still exhibit the gap.
|
||||
- Start: 2026-10-22T14:08:34Z. First reproduction completed by 14:11:03Z. No browser/gate wait; one Git metadata sandbox denial resolved by approved ff-only escalation.
|
||||
- Routing: dispatch message 20261022T140749; current campaign/rules permit investigation and unique evidence only. No product or retained test changes.
|
||||
- Reproducer: `/tmp/bitvid-storage-cors-3-audit.mjs`.
|
||||
- Command: `env -u NODE_TEST_CONTEXT node /tmp/bitvid-storage-cors-3-audit.mjs`.
|
||||
- Result: natural exit 1; 7 scenarios, 3 pass, 4 fail, 0 cancelled/skipped. Every fixture asserts zero attempted fetch/WebSocket calls.
|
||||
|
||||
## Real execution and virtualized boundaries
|
||||
|
||||
Real `NostrClient.subscribeVideos` creates the real `VideoEventBuffer`; a fake subscription-manager transport supplies synthetic kind-30078 events and explicit EOSE. Real conversion, root timestamps, tombstones, active/history maps, `NostrService.loadVideos`, filtering, notifications, service map, and filtered localStorage persistence execute. Synthetic verification accepts fixture IDs; unrelated NIP-71 metadata hydration and client IndexedDB scheduling are stubbed IO boundaries. No signer, relay, cloud, storage delete, browser, build or full suite runs.
|
||||
|
||||
The stale-refresh scenario also executes actual feed engine, active source and coordinator `refreshFeed`. A controlled deferred decorator holds one completed source snapshot while a second refresh finishes. `renderVideoList` is captured at the presentation boundary; actual DOM/card rendering and the entire registered production stage pipeline are not exercised.
|
||||
|
||||
## Passing controls
|
||||
|
||||
1. Live original at t100 and unrelated root at t110 populate active state and service notifications.
|
||||
2. Tombstone t200 followed by late nondeleted t90 leaves target absent from active map and actual active feed source; older stored event is marked deleted; unrelated remains.
|
||||
3. Tombstone t200 followed by live same-root t300 allows the newer content under existing timestamp semantics.
|
||||
|
||||
## Strict failures
|
||||
|
||||
1. **Service snapshot/persistence:** original t100 followed by tombstone t200 correctly removes original from client active map. Service removes only the tombstone event ID, leaving original ID in service map. Callback and persisted filtered list contain `original` with `deleted:true` plus unrelated. Expected only unrelated.
|
||||
2. **Reload/view consumer replacement:** another `loadVideos` emits `persistent-cache` containing original(deleted:true), then emits correct active cache containing unrelated only. Expected no deleted target in either notification. `shouldIncludeVideo` does not reject deleted entries.
|
||||
3. **Newer live content loss:** live t300 then late tombstone t200 leaves newer history object `deleted:false`, but removes it from active map. `recordTombstone` correctly preserves newer state; `VideoEventBuffer` subsequently unconditionally deletes the active key for a deletion marker. Expected newer and unrelated remain; actual unrelated only.
|
||||
4. **Stale refresh:** older run captures original/unrelated; delete arrives; newer refresh renders unrelated; release older run and it renders original(deleted:true)/unrelated last. Expected unrelated only. Coordinator applies async run results without checking current refresh/view ownership or deletion state.
|
||||
|
||||
`VideoListView.render` source inspection at lines353–410 shows filtering for NSFW/private, with no deleted guard. This is source evidence, not browser/DOM qualification.
|
||||
|
||||
## Proposed routing, not implementation
|
||||
|
||||
- `js/nostr/videoEventBuffer.js`: preserve live active entries newer than a deletion marker, keeping existing timestamp guard semantics.
|
||||
- `js/services/nostrService.js`: prevent deleted/tombstone-covered entries in public feed snapshots and optimistic persistence notifications, prune affected stale service entries rather than relying on matching tombstone event IDs. Preserve unrelated and strictly newer live content.
|
||||
- `js/app/feedCoordinator.js`: investigate smallest refresh/view ownership and final eligibility guard before rendering stale results; do not assume a same-feed request counter alone covers delayed cross-view work.
|
||||
- New unique retained integration regression after routing, with actual ingest/service and coordinator controls; old implementation must fail. No existing assertions changed.
|
||||
|
||||
Dispatch received reproduction/script/counts and proposed scopes at14:11:18Z. Await independent reproduction and precise ownership routing before product/test edits. Full registered pipeline, DOM/browser view switching, equal-timestamp edge cases, legacy d-tag fallback, real signature verification, NIP-09 kind5 ingest, restart persistence/IndexedDB, public relay/cloud behavior remain unverified. These findings concern existing kind30078 soft-delete markers; they do not establish additional deletion policy.
|
||||
|
||||
## Invocation limitation retained
|
||||
|
||||
An initial `env -u NODE_TEST_CONTEXT node --test /tmp/bitvid-storage-cors-3-audit.mjs` returned only a child-file failure (exit1, no scenario output). The direct Node invocation above exposes all seven scenarios naturally. No runner/gate configuration changed and the opaque wrapper result is not counted as product evidence.
|
||||
@@ -0,0 +1,38 @@
|
||||
# storage-cors-3 coordinator follow-on investigation
|
||||
|
||||
- Preserved candidate SHA: `89f8923c6e83b79a2b8874f811cdff2af6a809d0` (buffer/service commits already applied).
|
||||
- Authorized by dispatch14:25:40Z: offline registered coordinator/feed/view/disposal investigation only. Coordinator, app, client and retained tests unchanged. No browser/full/build/queue slot used.
|
||||
- Reproducer `/tmp/bitvid-storage-cors-3-registered-view-audit.mjs`; raw output `/tmp/storage3-registered-view-audit.log`.
|
||||
- Command `env -u NODE_TEST_CONTEXT node /tmp/bitvid-storage-cors-3-registered-view-audit.mjs`: natural exit1;4 scenarios,2 pass,2 fail,0 cancelled/skipped;182.585705ms test duration. Zero attempted global fetch/WebSocket calls asserted per fixture. This is JSDOM/offline proof, not native browser or complete application startup proof.
|
||||
- Completed and reported to dispatch by2026-10-22T14:29:48Z.
|
||||
|
||||
## Execution fidelity
|
||||
|
||||
Uses retained actual-subscription fixture from the buffer/service regressions: real client conversion/buffer/tombstone state and real service load/notifications/persistence. Synthetic signatures and fake subscription transport; metadata/IndexedDB IO virtualized as previously documented.
|
||||
|
||||
Creates real feed engine and invokes actual coordinator `registerRecentFeed` and `registerExploreFeed`. Source, blacklist, dedupe, moderation, posted-at and Explore scoring stages, and actual sorters execute. Uses real playback coordinator timestamp helpers and real timestamp utilities. Calls actual `loadFeedVideos`, `refreshFeed`, `renderVideoList`, `VideoListView.render` and `VideoCard` against a JSDOM document; asserts actual `[data-component="video-card"]` DOM IDs.
|
||||
|
||||
The controlled asynchronous IO boundary is the moderation adapter's `setActiveEventIds` method, which the registered moderation stage already awaits. Its first call containing `original` waits on an explicit deferred promise; subsequent calls resolve immediately. Moderator policy is no-reports/default, with other moderator methods absent; view-count subscriptions are disabled at the view adapter. App runtime builders provide empty/default runtimes; creator/moderation decorators are identity adapters, impression/similar-content/health-warning effects disabled. No custom engine stage/decorator or fake feed engine is inserted. This establishes registered pipeline and real list/card behavior under a controlled adapter latency, not real moderation relay timing or all navigation code.
|
||||
|
||||
The view replacement scenario calls real `VideoListView.destroy`, assigns a new real view, then calls actual `loadFeedVideos(EXPLORE)` which mounts it and changes actual feed telemetry ownership. It does not invoke the full application navigation/router or auth teardown entrypoint.
|
||||
|
||||
## Results
|
||||
|
||||
1. PASS: valid delayed Recent pipeline resolves and renders original+unrelated cards. Pending valid work is not categorically discarded.
|
||||
2. FAIL: Recent pipeline holds before deletion; actual deletion ingestion triggers current subscription refresh and DOM becomes unrelated-only. Releasing older Recent work restores original(deleted:true)+unrelated DOM cards. Expected unrelated-only throughout after current deletion refresh.
|
||||
3. FAIL: during the same overlap, destroy old view and mount replacement through actual Explore loader. Explore is active and its DOM contains unrelated-only. Releasing previous Recent work renders original(deleted:true)+unrelated into the replacement view; Explore metadata is overwritten by old Recent metadata. Expected unrelated-only and Explore ownership/metadata preserved.
|
||||
4. PASS: destroy the view and set `videoListView=null`, then release pending pipeline. Actual coordinator does not remount a fully absent view. No claim of a full-app destroy lifecycle test.
|
||||
|
||||
The earlier buffer/service fixes do not prevent a source snapshot already captured by an asynchronous registered pipeline from passing deleted objects to the presentation boundary after later deletion. Service current active/cache snapshots remain corrected; coordinator lifecycle/late-result application is a distinct boundary.
|
||||
|
||||
## Fixture development failures retained
|
||||
|
||||
- Initial JSDOM run lacked the global `HTMLElement` constructor required by actual `VideoCard`:4 errors, no product conclusion. Added scoped constructor from this JSDOM instance, restored in teardown.
|
||||
- Next attempt deferred timestamp-history IO, but all four scenarios cancelled waiting for an unreachable signal. Measured real `getKnownVideoPostedAt` returns converted `nip71Source.created_at`; no history request occurs. Debug script `/tmp/storage3-registered-view-debug.mjs` records this. Replaced the unreachable wait with the actual awaited moderation subscription adapter boundary; no timestamp fields were deleted or corrupted to force a path.
|
||||
- Final four scenarios above exit naturally with strict failures and passing controls. No sleeps, retries, force exits, timeouts, assertion relaxation or product changes.
|
||||
|
||||
## Proposed next scope (not authorized implementation)
|
||||
|
||||
Coordinator should validate freshness/view ownership before applying async results, and validate latest deletion eligibility before passing results or fallback snapshots to a view. New retained regression should exercise the actual registered path, include live/current/error fallback/disposal and cross-view controls, and reject the original coordinator. A request counter alone must not authorize older work to populate a replacement view or discard unrelated current feed work. Exact ownership and semantics require dispatch routing before any coordinator/app changes.
|
||||
|
||||
Native browser/full-app router/manual view switching, real moderation/signature/relay timing and complete auth disposal remain unverified. Existing buffer/service candidate remains the first READY gate; its full/build/normal submission slot is still pending.
|
||||
@@ -257,6 +257,12 @@
|
||||
"note": "after the reset: remove PAUSED, brief the session manager",
|
||||
"ignorePause": true,
|
||||
"quietHours": "allowed"
|
||||
},
|
||||
{
|
||||
"name": "resources",
|
||||
"cron": "*/15 * * * *",
|
||||
"note": "memory, disk, temp, battery, which area is heavy",
|
||||
"quietHours": "allowed"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
|
||||
@@ -168,3 +168,4 @@
|
||||
- Storage3 independently run env -u NODE_TEST_CONTEXT node /tmp/bitvid-dispatch-tombstone-audit.mjs at7369f1fe(runtime JS/tests/config diff0 vsc5b678f7):7tests,3pass/4fail,0network, naturalexit1/29.89ms. Deleted original persists/emits, reload briefly emits it, oldtombstone deletes strictly newer active, older refresh renders deleted reference after newer result. Buffer/service minimal fixes routed as separate commits with tests/mutants; coordinator open/read-only pending full registered-lifecycle fidelity. No browser/DOM or real-event signature qualification.
|
||||
- Client9 dispatch-independent11mode audit via /tmp/bitvid-dispatch-head-audit.mjs: disposed/same-turn/later exit1(3),shared/cross/legacy/live-success/error/timeout/late-success/error exit0(8),allcancelled0/naturalexit. Finalowner leavesHEAD1/abort0/timer1(5000ms)/unsettled/inflight;media slots0. Routed controller HEAD-only lifecycle cleanup+strict spec/integrity/regressions,including ignoring-abort/replacement/handoff controls;HLS/torrent/RangeGET remain separate/open. QA genuineartifact missing;offline exactcachedpackage reconstruction explicitly routed(no fetch/install scripts),before app-ready narrowbrowser.
|
||||
- QA3 native runner completed originalinstrumentedTest/globalsetup/unchanged3selectors:3pass/0fail/0flaky/0skip/retries0/workers1,naturalexit0/9955ms,release~14:22:11UTC. Worker evidence inspected earlier scratch controls;CI2terminal failures unreproduced,environment/access limits kept. No speculative fix/rerun;retain checkpoint docs and redirect QA4 mobile actualapp360/390/1280 strictoverflow+source/focus/touch,investigation only/exclusiveboundedChromium. Firefox remains open,not silently dropped.
|
||||
- Dispatch exact89f8923c archive registered feed/JSDOM repro4=2pass/2fail/cancelled0/naturalexit1/203.65ms confirms old refresh restores deletedDOM and overwrites replacement Explore metadata;validdelay/disposal controls pass. Separate storage4 coordinator-only lifecycle/result guard routed afterstorage3 tip frozen/submitted. No app.js writer;fullgate slot alreadygrantedstorage3,client9 next,QA4 heldsourceprep. Candidatecliente887ceaf independentnewHEAD23/23 naturalexit0/282.24ms;worker60focused/16mutants/lint11 retained,not rerun. No livebrowser/CPU/cloud proof.
|
||||
@@ -9,6 +9,8 @@ a landing rule changes.
|
||||
|
||||
## 2026-10-22
|
||||
|
||||
- Storage4 separately routed after dispatch exact89f8923c registered Recent/Explore+productionstages+actualVideoListView/Card JSDOM reproduction4=2pass/2fail/naturalexit1/203.65ms: old sameview result restores deletedDOM and old Recent overwrites remounted Explore metadata. Temporary ownership ONLY js/app/feedCoordinator.js async request/result/error/lifecycle eligibility + unique new registered lifecycle tests/notes. No app.js/view/engine/service/buffer edits for4; preserve live rendering/source/filter/moderation/ranking/return contracts. Guard stale success AND failure metadata/render/telemetry/sideeffects, newer refresh/view identity/disposal, preserve valid delayed pipeline/fallback. Freeze+submit existingstorage3 candidate first; storage4 separatecommit/item/normalgate, focused/mutants/lint then resource request. Current slot storage3 full/build;client9 next full;QA4 mobile HOLD/sourceprep, no unannounced competing jobs.
|
||||
|
||||
- QA3 native original instrumentedTest/globalsetup/3callbacks passed3/0fail/0flaky/0skip/retries0 with private genuine Buffer/public-deny guard; CI2 terminal assertions remain unreproduced, no causal fix. Retain docs-only checkpoint, keep CI issue open pending equivalent evidence; no speculative concurrency/repeated local runs. Existing QA moves to QA4 actual-app mobile Upload overflow360/390 plus1280 control, focus/touch/source-culprit measurement first, scratch/unique evidence only. Exclusive bounded Chromium browser slot granted after START; no product/CSS/tests/config/full/build before reproduced cause+ownership routing. Preserve original strict overflow failure, genuine fixture dependency provenance, preview3000, and live/manual limits.
|
||||
|
||||
- Client9 HEAD cancellation independently reproduced by dispatch11modes: disposed/same-turn/later fail3, shared/cross/legacy/live-success/error/timeout/late-success/error controls pass8, naturalexit/cancelled0. Finalowner retains HEAD1/abort0/timer1(5000ms)/unsettled inflight;media slots0 so no slot-leak claim. Route ONLY js/ui/urlHealthController.js HEAD lifecycle cancel/settle/timer-hook cleanup + focused lifecycle tests/unique strict integrity/spec notes. Explicit Stage3 contract extends immediate final-owner cleanup to active HEAD, preserving live/shared/legacy behavior. Abort transport AND settle via cancellation even if transport ignores abort; synchronous cancelled/rejoin identity safety; suppress obsolete fallback/cache/mirror/badge work. Preserve live error/timeout fallback, HEAD success/media handoff/FIFO, non-abort support. HEAD only; HLS/torrent and RangeGET separate. Focused/mutants/lint then request full/browser slot; QA conditional next narrow browser, no competing expensive gate.
|
||||
|
||||
+4
-3
@@ -12,8 +12,9 @@
|
||||
"docs/agents/schedules/winddown.md": "36429467ce62e1f26a7eb6c2e6111ecd490c18bf691ee3fdb73c2fda71838d74",
|
||||
"docs/agents/WIND-DOWN.md": "b27ae51b0b8e739e0fcca736e4cb44dfdd38b71fff1dd4261ff4df18d575df3a",
|
||||
"docs/agents/STRIKES.md": "dd3047e02287207a1de96c01d5e9a1889c2c37cb75a5b3655de6e813df7f5bd9",
|
||||
"docs/agents/prompts/interpreter.md": "6e47ffe144c8699ab32452efc9da3af1c898e0341276e41c4ba6e296fb1483d0",
|
||||
"docs/agents/prompts/session-manager.md": "0e2bd1d22b27e3ed39b12a5a0f68366acca85c52c97eb63aefaaa97d33eb6b44",
|
||||
".agents/skills/agent-fleet/SKILL.md": "25d5aeef4361b3eecff525f752d990acd2ce8988b59f790ba8363eece8e646c7"
|
||||
"docs/agents/prompts/interpreter.md": "776b16799a9516c108b19b22755796706aef3ee9ab98bc5d6ccc33542aae3f19",
|
||||
"docs/agents/prompts/session-manager.md": "c5c7358c439e8593792bd1a14d8dd7edf59e7f5c3f0b1f72b78293910b7c8635",
|
||||
".agents/skills/agent-fleet/SKILL.md": "25d5aeef4361b3eecff525f752d990acd2ce8988b59f790ba8363eece8e646c7",
|
||||
"docs/agents/schedules/resources.md": "1e1236eea30cd2d73f68d2093339cfb9db8a97784b9af3e7ab53edf773541072"
|
||||
}
|
||||
}
|
||||
@@ -168,16 +168,13 @@ export class VideoEventBuffer {
|
||||
this.client.allEvents.set(evt.id, video);
|
||||
this.client.dirtyEventIds.add(evt.id);
|
||||
|
||||
// If it's a "deleted" note, remove from activeMap
|
||||
// recordTombstone already removes active versions at or before the
|
||||
// deletion timestamp. A late/replayed marker must preserve newer ones.
|
||||
if (video.deleted) {
|
||||
if (activeKey) {
|
||||
if (wasDeletedEvent) {
|
||||
if (activeKey && !wasDeletedEvent) {
|
||||
const currentActive = this.client.activeMap.get(activeKey);
|
||||
if (currentActive?.id === video.id) {
|
||||
this.client.activeMap.delete(activeKey);
|
||||
} else {
|
||||
const currentActive = this.client.activeMap.get(activeKey);
|
||||
if (currentActive?.id === video.id) {
|
||||
this.client.activeMap.delete(activeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
updatedVideos.push(video);
|
||||
|
||||
@@ -1449,7 +1449,7 @@ export class NostrService {
|
||||
|
||||
const map = this.ensureVideosMap();
|
||||
for (const video of videos) {
|
||||
if (video && typeof video.id === "string" && video.id) {
|
||||
if (video && typeof video.id === "string" && video.id && !this.isVideoUnavailable(video)) {
|
||||
map.set(video.id, video);
|
||||
}
|
||||
}
|
||||
@@ -1516,11 +1516,17 @@ export class NostrService {
|
||||
return this.videosByAuthorIndex;
|
||||
}
|
||||
|
||||
isVideoUnavailable(video) {
|
||||
return video?.deleted === true || video?.invalid === true ||
|
||||
(typeof this.nostrClient?.isOlderThanTombstone === "function" &&
|
||||
this.nostrClient.isOlderThanTombstone(video));
|
||||
}
|
||||
|
||||
shouldIncludeVideo(video, {
|
||||
blacklistedEventIds = new Set(),
|
||||
isAuthorBlocked = () => false,
|
||||
} = {}) {
|
||||
if (!video || typeof video !== "object") {
|
||||
if (!video || typeof video !== "object" || this.isVideoUnavailable(video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1737,9 +1743,7 @@ export class NostrService {
|
||||
this.emit("videos:updated", { videos: filtered, reason });
|
||||
|
||||
// Persist the fresh filtered result for next time
|
||||
if (filtered.length > 0) {
|
||||
persistFilteredVideos(filtered);
|
||||
}
|
||||
persistFilteredVideos(filtered);
|
||||
|
||||
return filtered;
|
||||
};
|
||||
@@ -1758,6 +1762,7 @@ export class NostrService {
|
||||
|
||||
if (optimisticFiltered.length > 0) {
|
||||
this.cacheVideos(optimisticFiltered);
|
||||
persistFilteredVideos(optimisticFiltered);
|
||||
hasOptimisticData = true;
|
||||
optimisticResult = optimisticFiltered;
|
||||
|
||||
@@ -1821,9 +1826,12 @@ export class NostrService {
|
||||
|
||||
if (deletedUpdates.length > 0) {
|
||||
const map = this.ensureVideosMap();
|
||||
for (const v of deletedUpdates) {
|
||||
if (v.id) {
|
||||
map.delete(v.id);
|
||||
const deletedIds = new Set(deletedUpdates.map((video) => video.id));
|
||||
// Deletion markers have their own event IDs. Consult the
|
||||
// client's authoritative timestamp/key guard for cached copies.
|
||||
for (const [id, video] of map) {
|
||||
if (deletedIds.has(id) || this.isVideoUnavailable(video)) {
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
this.markAuthorIndexDirty();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- SHA c5b678f7c9d0b92fbf786b16ae831f1be1d0b899.
|
||||
- `env -u NODE_TEST_CONTEXT node --test /tmp/bitvid-storage-cors-3-audit.mjs`: exit1, opaque child-file failure, no scenario detail; not product evidence.
|
||||
- `env -u NODE_TEST_CONTEXT node /tmp/bitvid-storage-cors-3-audit.mjs`: natural exit1; seven scenarios, three pass, four fail, zero cancelled/skipped. Zero attempted network per fixture.
|
||||
- Failures: deleted original remains in service callback/persisted list; optimistic reload emits deleted original; late older tombstone removes newer active entry; stale engine run renders deleted original after fresh refresh.
|
||||
- Passing controls: initial live/unrelated ingestion, tombstone+late older absent from active source, newer post-tombstone content allowed.
|
||||
- No product/retained tests modified, lint/build/full/browser not run. Limits and transport details in docs/testing/storage-cors-3-investigation-2026-10-22.md.
|
||||
@@ -0,0 +1,7 @@
|
||||
- Baseline merged HEAD89c5494aaa23ae2ce2c1c044d0146c922959cdcf.
|
||||
- `env -u NODE_TEST_CONTEXT node tests/storage-tombstone-buffer.test.mjs`: old buffer exit1,2pass/3fail; fixed exit0,5pass/0fail.
|
||||
- `python3 /tmp/storage3-buffer-mutants.py`: two mutants each exit1, failures3 and5; original file restored in finally. Every new scenario rejects at least one mutant.
|
||||
- `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.mjs tests/storage-tombstone-buffer.test.mjs tests/nostr-delete-flow.test.mjs`: exit0,5+4 pass.
|
||||
- `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.mjs tests/nostr/videoEventBuffer.test.mjs`: exit0,6pass.
|
||||
- Mistyped nonexistent existing test path failed before tests and was corrected; retained in integrity note.
|
||||
- Lint pending after second scoped service fix. No full/build/browser/product submission while QA owns resource slot. Coordinator stale render unresolved.
|
||||
@@ -0,0 +1,10 @@
|
||||
- Product buffer commit ed5fb64991ae4aaf5df4ac76f4bd13ca9633aea0; service edits not yet committed when checked.
|
||||
- `env -u NODE_TEST_CONTEXT node tests/storage-tombstone-service.test.mjs`: old service natural exit1,0pass/8fail; fixed exit0,8pass.
|
||||
- `python3 /tmp/storage3-service-mutants.py`: exit0,8 deliberately broken variants each exited1, product restored; failing scenario counts8/4/4/2/5/1/1/2.
|
||||
- Focused command: `env -u NODE_TEST_CONTEXT node scripts/run-targeted-tests.mjs tests/storage-tombstone-buffer.test.mjs tests/storage-tombstone-service.test.mjs tests/nostr/videoEventBuffer.test.mjs tests/nostr-delete-flow.test.mjs tests/nostr-service-live-feed-notification.test.mjs tests/nostr-service-access-control.test.mjs tests/nostr-service-access-control-ready.test.mjs tests/services/nostr-service.test.mjs tests/author-moderation-override.test.mjs tests/moderation-stage.test.mjs tests/app/feedCoordinator.test.mjs tests/app/feedCoordinatorWatchHistoryRefresh.test.mjs`.
|
||||
- Final focused exit0 across12 files:11 files summarize59 passing tests; existing access-control suite outputs5 named passing checks and exit0 but no final TAP summary. That output limit is disclosed; not called a complete footer. New tests13/13 across two files.
|
||||
- Initial focused run stopped at existing live-feed test4 (6pass/1fail); explicit same-event-ID removal restored alongside new cleanup; no test changes.
|
||||
- `env -u NODE_TEST_CONTEXT npm run lint`: initial sandbox exit2 at git-grep EPERM; approved unchanged command outside sandbox exits0,all11 stages.
|
||||
- `git diff --check`: exit0. Service net growth8 lines; grandfather limit passes.
|
||||
- Raw logs `/tmp/storage3-focused.log`, `/tmp/storage3-lint.log`, `/tmp/storage3-service-baseline.log`, `/tmp/storage3-service-mutant-results.log`.
|
||||
- No browser/full/build/queue. Coordinator stale-render remains unresolved/read-only. Live signer/relay/cloud/IndexedDB restart and DOM/full registered pipeline unverified.
|
||||
@@ -0,0 +1,8 @@
|
||||
- Exact candidate89f8923c6e83b79a2b8874f811cdff2af6a809d0 inspected via git diff e04549f8: product delta only videoEventBuffer and nostrService, unique tests/helper/evidence. Buffer preserves authoritative timestamp guard; service excludes deleted/invalid/tombstone records and persists empty results.
|
||||
- git archive89f8923c js tests config vendor package.json extracted to isolated /tmp/bitvid-dispatch-storage3-candidate; node_modules linked read-only existing dependency source. No peer worktree mutation.
|
||||
- env -u NODE_TEST_CONTEXT node --test <two absolute newtestpaths>:exit0,2 file-level summaries,203.62ms; not counted as13 scenarios.
|
||||
- Direct env -u NODE_TEST_CONTEXT node <isolated>/tests/storage-tombstone-buffer.test.mjs:5pass/0fail/0cancelled/0skip,naturalexit0,20.513356ms.
|
||||
- Direct env -u NODE_TEST_CONTEXT node <isolated>/tests/storage-tombstone-service.test.mjs:8pass/0fail/0cancelled/0skip,naturalexit0,22.292178ms.
|
||||
- Worker broader12file focused59summarized+5named/nofooter,10killed restored mutants,lint11 are reported evidence,not rerun here. No independent full/build/browser/live signature/relay/cloud/DOM proof.
|
||||
- QA3 checkpoint48999ca2 durably4de357e4 status/log/ancestry0. QA confirms mobile grant unconsumed/HOLD14:26:01; storage exclusive normalgate/build/artifact slot granted14:26:33. Use authoritative unchanged lander fullgate once,no duplicatefull.
|
||||
- Isolated scratch tree removed by literal path after checks. Product fixes not yet landed/qualified at this checkpoint.
|
||||
@@ -0,0 +1,7 @@
|
||||
- Committed fixes:buffer ed5fb64991ae4aaf5df4ac76f4bd13ca9633aea0;service fb1997de42dd1d62f2b5be30150bcab350010152.
|
||||
- Merged latest local unstable e04549f8 into own89f8923c6e83b79a2b8874f811cdff2af6a809d0. Merge had only coordination docs; runtime/test/script diff0 from checked product tip.
|
||||
- Merged rerun of12-file focused command from14:21log:exit0,59 summarized passing tests plus5 named access-control PASS lines/exit0 without final TAP footer. New tests13/13; all10 restored mutants previously killed.
|
||||
- Merged `env -u NODE_TEST_CONTEXT npm run lint` outside sandbox for approved git-grep exception:exit0,11stages. Logs /tmp/storage3-merged-focused.log and /tmp/storage3-merged-lint.log.
|
||||
- READY/full slot request sent14:22:48Z; dispatch14:25:40Z confirms first READY queue, QA still holds browser/full slot. No full/build/normal queue started; no rerunning passing checks merely waiting.
|
||||
- `env -u NODE_TEST_CONTEXT node /tmp/bitvid-storage-cors-3-registered-view-audit.mjs`:naturalexit1,2pass/2fail/0cancelled/0skipped,182.585705ms. Actual registered Recent/Explore pipelines and JSDOM view/card DOM. Preliminary fixture failures and exact controlled boundaries retained in unique registered-view evidence note.
|
||||
- Coordinator remains read-only/open. No native browser/full-app/router/live cloud/signer/relay qualification.
|
||||
@@ -0,0 +1,4 @@
|
||||
- Isolated git archive snapshots89f8923c(js/tests/config/vendor/package.json) ande887ceaf likewise, read-only dependency link. No peer worktree mutation.
|
||||
- env -u NODE_TEST_CONTEXT node /tmp/bitvid-dispatch-registered-view-audit.mjs imports redirected only to exactstorage89f8923c:4tests/2pass/2fail/cancelled0/skipped0,naturalexit1,203.649518ms. Same-view obsolete result rendersoriginaldeletedDOM; oldRecent overwrites Explore DOM/metadata after real destroy/remount. Controlsvaliddelayed/full disposal pass. Real registered sources/production stages/coordinator/view/card; JSDOM+controlledmoderationIO,syntheticverified events/no network;notnativebrowser/live protocol proof.
|
||||
- env -u NODE_TEST_CONTEXT node /tmp/bitvid-dispatch-client9-review/tests/url-health-head-lifecycle.test.mjs:23tests/23pass/0fail/cancelled0/skipped0,naturalexit0,282.237644ms. Exactcandidatee887ceaf controllerdelta inspected, onlyHEADproductphase. Worker60focused/16killedmutants/lint11 reported,not duplicated.
|
||||
- Both scratch snapshot directories removed after checks by literal path. No broad/full/build/browser run here. Storage3 granted authoritative unchangednormalgate/build14:26:33;client9 nextfull,QA4 held.
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tombstoneFixture, videoEvent, ids } from './test-helpers/storage-tombstone-fixture.mjs';
|
||||
|
||||
// Scenarios and deterministic IO boundaries: docs/testing/storage-cors-3-integrity.md.
|
||||
for (const order of ['newer-first', 'delete-first', 'same-batch']) {
|
||||
test(`buffer preserves strictly newer active content: ${order}`, async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
const newer = videoEvent('newer', 300);
|
||||
const deletion = videoEvent('deletion', 200, { deleted: true });
|
||||
if (order === 'same-batch') await f.ingest(newer, deletion);
|
||||
else if (order === 'newer-first') { await f.ingest(newer); await f.ingest(deletion); }
|
||||
else { await f.ingest(deletion); await f.ingest(newer); }
|
||||
await f.ingest(videoEvent('unrelated', 100, { root: 'other', pubkey: 'b'.repeat(64) }));
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']);
|
||||
assert.equal(f.client.allEvents.get('newer').deleted, false);
|
||||
assert.equal(f.client.tombstones.get('ROOT:target'), 200);
|
||||
// Replayed markers must not erase a newer version either.
|
||||
await f.ingest(deletion);
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['newer', 'unrelated']);
|
||||
});
|
||||
}
|
||||
|
||||
test('newer/equal tombstone removes older versions, late zombies stay absent, newer content may return', async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
await f.ingest(videoEvent('first', 100), videoEvent('edit', 150), videoEvent('other', 100, { root: 'other' }));
|
||||
await f.ingest(videoEvent('delete', 150, { deleted: true }));
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['other']);
|
||||
assert.equal(f.client.allEvents.get('first').deleted, true);
|
||||
assert.equal(f.client.allEvents.get('edit').deleted, true);
|
||||
await f.ingest(videoEvent('late', 120));
|
||||
assert.equal(f.client.allEvents.get('late').deleted, true);
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['other']);
|
||||
await f.ingest(videoEvent('fresh', 151));
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['fresh', 'other']);
|
||||
});
|
||||
|
||||
test('legacy d-tag tombstones retain authoritative author scoping', async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
await f.ingest(videoEvent('target-legacy', 100, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' }), videoEvent('other-author', 100, { root: `LEGACY:${'b'.repeat(64)}:same`, dTag: 'same', pubkey: 'b'.repeat(64) }));
|
||||
await f.ingest(videoEvent('delete-legacy', 200, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same', deleted: true }));
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']);
|
||||
await f.ingest(videoEvent('target-late', 90, { root: `LEGACY:${'a'.repeat(64)}:same`, dTag: 'same' }));
|
||||
assert.deepEqual(ids(f.client.getActiveVideos()), ['other-author']);
|
||||
assert.equal(f.client.allEvents.get('target-late').deleted, true);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tombstoneFixture, videoEvent, ids, author, filteredStorageKey } from './test-helpers/storage-tombstone-fixture.mjs';
|
||||
|
||||
const cachedVideo = (id, extra = {}) => ({ id, pubkey: author, videoRootId: id, created_at: 100, title: id, url: 'https://example.invalid/video.mp4', ...extra });
|
||||
const assertSnapshot = (f, expected) => {
|
||||
assert.deepEqual(ids([...f.service.getVideosMap().values()]), expected);
|
||||
assert.deepEqual(ids(f.notifications.at(-1).videos), expected);
|
||||
assert.deepEqual(ids(f.updates.at(-1).videos), expected);
|
||||
assert.deepEqual(ids(f.persisted()), expected);
|
||||
};
|
||||
|
||||
test('different-ID tombstone removes every obsolete cached version and preserves unrelated live content', async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
await f.ingest(videoEvent('first', 100));
|
||||
await f.ingest(videoEvent('edit', 150), videoEvent('other', 100, { root: 'other', pubkey: 'b'.repeat(64) }));
|
||||
await f.ingest(videoEvent('delete', 200, { deleted: true }));
|
||||
assertSnapshot(f, ['other']);
|
||||
assert.deepEqual(ids(f.updates.at(-1).deleted), ['delete']);
|
||||
await f.ingest(videoEvent('late', 120));
|
||||
assertSnapshot(f, ['other']);
|
||||
});
|
||||
|
||||
test('deletion-only empty result persists and second consumer receives no optimistic resurrection', async t => {
|
||||
const f = tombstoneFixture(t); await f.load(); await f.ingest(videoEvent('first', 100));
|
||||
await f.ingest(videoEvent('delete', 200, { deleted: true }));
|
||||
assertSnapshot(f, []);
|
||||
const count = f.notifications.length;
|
||||
await f.load();
|
||||
assert.deepEqual(f.notifications.slice(count).map(entry => ids(entry.videos)), [[]]);
|
||||
assert.deepEqual(f.persisted(), []);
|
||||
});
|
||||
|
||||
for (const viewer of ['public', 'author']) {
|
||||
test(`persistent deleted and invalid entries are rejected before ${viewer} access bypass`, async t => {
|
||||
const f = tombstoneFixture(t, { persisted: [cachedVideo('deleted', { deleted: true }), cachedVideo('invalid', { invalid: true }), cachedVideo('live')] });
|
||||
if (viewer === 'author') f.client.pubkey = author;
|
||||
const initial = await f.load();
|
||||
assert.deepEqual(ids(initial), ['live']);
|
||||
assert.equal(f.notifications.some(entry => entry.videos.some(video => video.deleted || video.invalid)), false);
|
||||
assert.deepEqual(ids([...f.service.getVideosMap().values()]), ['live']);
|
||||
assert.deepEqual(ids(f.persisted()), ['live']);
|
||||
});
|
||||
}
|
||||
|
||||
test('all-ineligible persistent cache is cleared even when active snapshot is empty', async t => {
|
||||
const f = tombstoneFixture(t, { persisted: [cachedVideo('deleted', { deleted: true }), cachedVideo('invalid', { invalid: true })] });
|
||||
assert.deepEqual(await f.load(), []);
|
||||
assert.deepEqual(f.persisted(), []);
|
||||
assert.deepEqual(f.notifications.map(entry => ids(entry.videos)), [[]]);
|
||||
});
|
||||
|
||||
test('stale nondeleted persistent copies honor known tombstone while strictly newer and unrelated entries survive', async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
await f.ingest(videoEvent('old', 100));
|
||||
const stale = f.persisted()[0];
|
||||
await f.ingest(videoEvent('delete', 200, { deleted: true }));
|
||||
await f.ingest(videoEvent('newer', 300), videoEvent('other', 150, { root: 'other' }));
|
||||
// A stale storage snapshot contains an independent, still-nondeleted copy.
|
||||
localStorage.setItem(filteredStorageKey, JSON.stringify([stale, ...f.persisted()]));
|
||||
const start = f.notifications.length;
|
||||
await f.load();
|
||||
assert.equal(f.notifications.slice(start).some(entry => entry.videos.some(video => video.id === 'old')), false);
|
||||
assertSnapshot(f, ['newer', 'other']);
|
||||
});
|
||||
|
||||
test('direct cache API never reintroduces deleted invalid or tombstone-covered copies', async t => {
|
||||
const f = tombstoneFixture(t); await f.load(); await f.ingest(videoEvent('old', 100));
|
||||
const stale = f.persisted()[0];
|
||||
await f.ingest(videoEvent('delete', 200, { deleted: true }));
|
||||
f.service.cacheVideos([stale, cachedVideo('deleted', { deleted: true }), cachedVideo('invalid', { invalid: true }), cachedVideo('live')]);
|
||||
assert.deepEqual(ids([...f.service.getVideosMap().values()]), ['live']);
|
||||
});
|
||||
|
||||
test('legacy tombstone cleanup retains other author with same d-tag and strictly newer content', async t => {
|
||||
const f = tombstoneFixture(t); await f.load();
|
||||
const legacy = { root: `LEGACY:${author}:same`, dTag: 'same' };
|
||||
const other = { root: `LEGACY:${'b'.repeat(64)}:same`, dTag: 'same', pubkey: 'b'.repeat(64) };
|
||||
await f.ingest(videoEvent('old', 100, legacy), videoEvent('other-author', 100, other));
|
||||
await f.ingest(videoEvent('delete', 200, { ...legacy, deleted: true }));
|
||||
assertSnapshot(f, ['other-author']);
|
||||
await f.ingest(videoEvent('newer', 300, legacy));
|
||||
await f.ingest(videoEvent('delete-replayed', 200, { ...legacy, deleted: true }));
|
||||
assertSnapshot(f, ['newer', 'other-author']);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import './setup-localstorage.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NostrClient } from '../../js/nostr/client.js';
|
||||
import { NostrService } from '../../js/services/nostrService.js';
|
||||
import { getVideosMap, setVideosMap, getVideoSubscription, setVideoSubscription } from '../../js/state/appState.js';
|
||||
|
||||
export const filteredStorageKey = 'bitvid:filtered-videos:v1';
|
||||
export const author = 'a'.repeat(64);
|
||||
export function videoEvent(id, created_at, { root = 'target', deleted = false, pubkey = author, dTag = root, ...payload } = {}) {
|
||||
return {
|
||||
id, kind: 30078, pubkey, created_at,
|
||||
tags: [['t', 'video'], ['d', dTag]],
|
||||
content: JSON.stringify({ version: 3, title: id, url: 'https://example.invalid/video.mp4', mode: 'live', videoRootId: root, deleted, ...payload }),
|
||||
};
|
||||
}
|
||||
export const ids = videos => videos.map(video => video.id).sort();
|
||||
|
||||
export function tombstoneFixture(t, { persisted = [] } = {}) {
|
||||
const previous = { map: getVideosMap(), sub: getVideoSubscription(), storage: localStorage.getItem(filteredStorageKey) };
|
||||
setVideosMap(new Map()); setVideoSubscription(null);
|
||||
localStorage.setItem(filteredStorageKey, JSON.stringify(persisted));
|
||||
let networkAttempts = 0;
|
||||
const oldFetch = globalThis.fetch;
|
||||
const oldSocket = globalThis.WebSocket;
|
||||
globalThis.fetch = () => { networkAttempts++; throw new Error('Unexpected network'); };
|
||||
globalThis.WebSocket = class { constructor() { networkAttempts++; throw new Error('Unexpected socket'); } };
|
||||
const client = new NostrClient();
|
||||
// Virtualize unrelated metadata and IndexedDB IO, keeping all ingest,
|
||||
// tombstone, active/history, service and filtered-localStorage logic real.
|
||||
client.saveLocalData = () => {};
|
||||
client.populateNip71MetadataForVideos = async () => {};
|
||||
client.videoEventVerifier = async events => new Set(events.map(event => event.id));
|
||||
const channels = [];
|
||||
client._subscriptionManager = { subscribe(config) {
|
||||
const channel = { ...config, closed: false };
|
||||
channels.push(channel);
|
||||
return { close() { channel.closed = true; } };
|
||||
} };
|
||||
const service = new NostrService();
|
||||
service.nostrClient = client;
|
||||
service.moderationService = null;
|
||||
service.accessControl = { isHydrated: () => true, ensureReady: async () => {}, canAccess: video => video.title !== 'denied' };
|
||||
const notifications = [];
|
||||
const updates = [];
|
||||
const off = service.on('videos:updated', detail => updates.push(structuredClone(detail)));
|
||||
const load = (options = {}) => service.loadVideos({ onVideos: (videos, detail) => notifications.push({ videos: structuredClone(videos), ...detail }), ...options });
|
||||
t.after(() => {
|
||||
service.clearVideoSubscription(); off();
|
||||
setVideosMap(previous.map); setVideoSubscription(previous.sub);
|
||||
if (previous.storage === null) localStorage.removeItem(filteredStorageKey);
|
||||
else localStorage.setItem(filteredStorageKey, previous.storage);
|
||||
globalThis.fetch = oldFetch; globalThis.WebSocket = oldSocket;
|
||||
assert.equal(networkAttempts, 0, 'no real network attempted');
|
||||
assert.ok(channels.every(channel => channel.closed), 'subscriptions naturally closed');
|
||||
});
|
||||
return { client, service, channels, notifications, updates, load,
|
||||
async ingest(...events) {
|
||||
const channel = channels.at(-1);
|
||||
for (const event of events) channel.onEvent(event);
|
||||
await channel.onEose();
|
||||
},
|
||||
persisted: () => JSON.parse(localStorage.getItem(filteredStorageKey) || '[]'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] Read mail/current campaign/rules; safely ff own clean branch; claim storage-cors-3 and acknowledge.
|
||||
- [x] Trace real client/buffer/service/source/coordinator; reproduce offline with strict controls.
|
||||
- [x] Report seven scenarios/three pass/four fail and proposed scopes to dispatch.
|
||||
- [ ] Independent confirmation and precise implementation routing; product/retained tests remain read-only meanwhile.
|
||||
- [ ] Full registered pipeline/view-switch regression, failing mutants, required gates and durable landing only after routing/resource grant.
|
||||
@@ -0,0 +1,748 @@
|
||||
// One snapshot of the machine and of each area's process tree.
|
||||
// Missing sensors are "unavailable". Nothing here changes the machine.
|
||||
// The health block in fleet.config.json is guidance for the prompts.
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const HEALTH_DEFAULTS = {
|
||||
diskFreePct: 10,
|
||||
diskFreeGb: 5,
|
||||
memoryAvailablePct: 15,
|
||||
swapUsedPct: 50,
|
||||
cpuTempC: 90,
|
||||
batteryPct: 20,
|
||||
batteryMinutes: 30,
|
||||
loadPerCore: 1.5,
|
||||
};
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
export function healthThresholds(raw) {
|
||||
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
const out = { ...HEALTH_DEFAULTS };
|
||||
for (const key of Object.keys(HEALTH_DEFAULTS)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(src, key)) continue;
|
||||
const n = Number(src[key]);
|
||||
if (Number.isFinite(n) && n >= 0) out[key] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseLoadavg(text) {
|
||||
const m = /^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)/.exec(String(text || '').trim());
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
export function parseMeminfo(text) {
|
||||
const map = {};
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^(\w+):\s+(\d+)\s*kB/i.exec(line);
|
||||
if (m) map[m[1]] = Number(m[2]) * 1024;
|
||||
}
|
||||
if (map.MemTotal == null) return null;
|
||||
const total = map.MemTotal;
|
||||
const available = map.MemAvailable ?? map.MemFree ?? null;
|
||||
const free = map.MemFree ?? null;
|
||||
const swapTotal = map.SwapTotal ?? 0;
|
||||
const swapFree = map.SwapFree ?? (swapTotal ? 0 : null);
|
||||
return {
|
||||
totalBytes: total,
|
||||
availableBytes: available,
|
||||
freeBytes: free,
|
||||
usedBytes: available != null ? Math.max(0, total - available) : null,
|
||||
swapTotalBytes: map.SwapTotal == null ? null : swapTotal,
|
||||
swapFreeBytes: map.SwapTotal == null ? null : swapFree,
|
||||
swapUsedBytes: map.SwapTotal == null ? null : Math.max(0, swapTotal - (swapFree ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** /proc/stat "cpu" line, or a sum of os.cpus().times. idle includes iowait. */
|
||||
export function parseCpuStat(text) {
|
||||
const line = String(text || '').split('\n').find((l) => /^cpu\s+/.test(l));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
if (n.length < 4 || n.some((x) => !Number.isFinite(x))) return null;
|
||||
const idle = n[3] + (n[4] || 0);
|
||||
const total = n.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
export function cpuTimes(cpus = os.cpus()) {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of cpus || []) {
|
||||
for (const [key, value] of Object.entries(cpu.times || {})) {
|
||||
total += value;
|
||||
if (key === 'idle') idle += value;
|
||||
}
|
||||
}
|
||||
if (total <= 0) return null;
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
export function cpuUsagePct(before, after) {
|
||||
if (!before || !after) return null;
|
||||
const total = after.total - before.total;
|
||||
const idle = after.idle - before.idle;
|
||||
if (total <= 0) return null;
|
||||
return Math.round((1000 * (total - idle)) / total) / 10;
|
||||
}
|
||||
|
||||
/** One line of /proc/<pid>/stat. rss is pages times pageSize (default 4096). */
|
||||
export function parseProcStat(text, pageSize = 4096) {
|
||||
const s = String(text || '');
|
||||
const end = s.lastIndexOf(')');
|
||||
const space = s.indexOf(' ');
|
||||
if (end < 0 || space < 0) return null;
|
||||
const pid = Number(s.slice(0, space));
|
||||
const rest = s.slice(end + 2).trim().split(/\s+/);
|
||||
if (rest.length < 22) return null;
|
||||
const ppid = Number(rest[1]);
|
||||
const utime = Number(rest[11]);
|
||||
const stime = Number(rest[12]);
|
||||
const rssPages = Number(rest[21]);
|
||||
if (![pid, ppid, utime, stime, rssPages].every(Number.isFinite)) return null;
|
||||
return { pid, ppid, ticks: utime + stime, rssBytes: rssPages * pageSize };
|
||||
}
|
||||
|
||||
export function readProcSnapshot(sysRoot, pageSize = 4096) {
|
||||
const dir = path.join(sysRoot, 'proc');
|
||||
const rows = [];
|
||||
for (const name of safeReaddir(dir)) {
|
||||
if (!/^\d+$/.test(name)) continue;
|
||||
const text = readText(path.join(dir, name, 'stat'));
|
||||
if (!text) continue;
|
||||
const row = parseProcStat(text, pageSize);
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU% and RSS for rootPid and every descendant.
|
||||
* ticks are USER_HZ (default 100). A process that appears only in `after`
|
||||
* counts its whole tick count. cpuPct may exceed 100 when several cores are busy.
|
||||
*/
|
||||
export function treeUse(before, after, rootPid, elapsedMs, hz = 100) {
|
||||
const rows = [];
|
||||
const prev = new Map((before || []).map((p) => [p.pid, p]));
|
||||
const elapsedTicks = (Number(elapsedMs) / 1000) * hz;
|
||||
for (const now of after || []) {
|
||||
const was = prev.get(now.pid);
|
||||
const delta = was ? Math.max(0, now.ticks - was.ticks) : now.ticks;
|
||||
const cpuPct = elapsedTicks > 0 ? (100 * delta) / elapsedTicks : null;
|
||||
rows.push({ pid: now.pid, ppid: now.ppid, rssBytes: now.rssBytes, cpuPct });
|
||||
}
|
||||
const sum = sumTree(rows, rootPid);
|
||||
if (!sum.found) return sum;
|
||||
return { ...sum, cpuPct: sum.cpuPct == null ? null : Math.round(sum.cpuPct * 10) / 10 };
|
||||
}
|
||||
|
||||
/** rows: { pid, ppid, cpuPct, rssBytes }. cpuPct null stays null if every row is null. */
|
||||
export function sumTree(rows, rootPid) {
|
||||
const byPid = new Map();
|
||||
const children = new Map();
|
||||
for (const row of rows || []) {
|
||||
byPid.set(row.pid, row);
|
||||
if (!children.has(row.ppid)) children.set(row.ppid, []);
|
||||
children.get(row.ppid).push(row.pid);
|
||||
}
|
||||
const seen = new Set();
|
||||
const stack = [rootPid];
|
||||
let rss = 0;
|
||||
let cpu = 0;
|
||||
let cpuKnown = false;
|
||||
let count = 0;
|
||||
while (stack.length) {
|
||||
const pid = stack.pop();
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
const row = byPid.get(pid);
|
||||
if (!row) continue;
|
||||
count += 1;
|
||||
rss += row.rssBytes || 0;
|
||||
if (row.cpuPct != null) { cpu += row.cpuPct; cpuKnown = true; }
|
||||
for (const kid of children.get(pid) || []) stack.push(kid);
|
||||
}
|
||||
if (!count) return { found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { found: true, cpuPct: cpuKnown ? Math.round(cpu * 10) / 10 : null, rssBytes: rss, pids: count };
|
||||
}
|
||||
|
||||
export function parseDf(text) {
|
||||
const lines = String(text || '').split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) return null;
|
||||
const line = lines[lines.length - 1];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return null;
|
||||
const total = Number(parts[1]);
|
||||
const used = Number(parts[2]);
|
||||
const free = Number(parts[3]);
|
||||
if (![total, used, free].every((n) => Number.isFinite(n) && n >= 0)) return null;
|
||||
const freePct = total > 0 ? Math.round((1000 * free) / total) / 10 : null;
|
||||
return { mount: parts.slice(5).join(' '), totalBytes: total, usedBytes: used, freeBytes: free, freePct };
|
||||
}
|
||||
|
||||
export function parseVmStat(text) {
|
||||
const page = /page size of (\d+) bytes/i.exec(text || '');
|
||||
const pages = {};
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const m = /^Pages (.+?):\s+(\d+)/.exec(line.trim());
|
||||
if (m) pages[m[1].toLowerCase()] = Number(m[2]);
|
||||
}
|
||||
return { pageSize: page ? Number(page[1]) : 4096, pages };
|
||||
}
|
||||
|
||||
export function parseSwapUsage(text) {
|
||||
const unit = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
||||
const grab = (name) => {
|
||||
const m = new RegExp(`${name}\\s*=\\s*([\\d.]+)\\s*([KMGT])`, 'i').exec(text || '');
|
||||
if (!m) return null;
|
||||
return Math.round(Number(m[1]) * (unit[m[2].toUpperCase()] || 1));
|
||||
};
|
||||
const total = grab('total');
|
||||
if (total == null) return { totalBytes: null, usedBytes: null, freeBytes: null };
|
||||
return { totalBytes: total, usedBytes: grab('used'), freeBytes: grab('free') };
|
||||
}
|
||||
|
||||
export function parsePmset(text) {
|
||||
const body = String(text || '');
|
||||
if (!body.trim() || /no batteries/i.test(body) && !/\d+\s*%/.test(body)) return emptyBattery();
|
||||
const line = body.split('\n').find((l) => /\d+\s*%/.test(l));
|
||||
if (!line) return emptyBattery();
|
||||
const pct = /(\d+)\s*%/.exec(line);
|
||||
let state = 'unavailable';
|
||||
if (/discharg/i.test(line)) state = 'discharging';
|
||||
else if (/charg(ing|ed)/i.test(line) && !/not charging/i.test(line) && /charging/i.test(line)) state = 'charging';
|
||||
else if (/charged|full/i.test(line)) state = 'full';
|
||||
else if (/not charging/i.test(line)) state = 'not-charging';
|
||||
else if (/AC Power/i.test(body)) state = 'not-charging';
|
||||
else if (/Battery Power/i.test(body)) state = 'discharging';
|
||||
let minutes = null;
|
||||
const tm = /(\d+):(\d+)\s+remaining/.exec(line);
|
||||
if (tm && !/no estimate/i.test(line)) minutes = Number(tm[1]) * 60 + Number(tm[2]);
|
||||
return { present: true, percent: pct ? Number(pct[1]) : null, state, minutesRemaining: minutes };
|
||||
}
|
||||
|
||||
export function parsePs(text) {
|
||||
const rows = [];
|
||||
for (const line of String(text || '').split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const pid = Number(parts[0]);
|
||||
const ppid = Number(parts[1]);
|
||||
const rssKb = Number(parts[2]);
|
||||
const cpu = Number(parts[3]);
|
||||
if (![pid, ppid, rssKb, cpu].every(Number.isFinite)) continue;
|
||||
rows.push({ pid, ppid, rssBytes: rssKb * 1024, cpuPct: cpu });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function emptyBattery() {
|
||||
return { present: false, percent: null, state: 'unavailable', minutesRemaining: null };
|
||||
}
|
||||
|
||||
function emptyMemory() {
|
||||
return {
|
||||
status: 'unavailable', totalBytes: null, usedBytes: null, freeBytes: null,
|
||||
availableBytes: null, availablePct: null, swapTotalBytes: null, swapUsedBytes: null, swapFreeBytes: null,
|
||||
};
|
||||
}
|
||||
|
||||
function withMemoryPct(mem) {
|
||||
if (!mem || mem.totalBytes == null) return emptyMemory();
|
||||
const availablePct = mem.availableBytes != null && mem.totalBytes > 0
|
||||
? Math.round((1000 * mem.availableBytes) / mem.totalBytes) / 10
|
||||
: null;
|
||||
return { status: 'ok', ...mem, availablePct };
|
||||
}
|
||||
|
||||
function readText(file) {
|
||||
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
|
||||
}
|
||||
|
||||
function readNum(file) {
|
||||
const text = readText(file);
|
||||
if (text == null) return null;
|
||||
const n = Number(String(text).trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function safeReaddir(dir) {
|
||||
try { return fs.readdirSync(dir); } catch { return []; }
|
||||
}
|
||||
|
||||
function toC(raw) {
|
||||
if (raw == null || !Number.isFinite(raw)) return null;
|
||||
const c = Math.abs(raw) >= 1000 ? raw / 1000 : raw;
|
||||
if (c <= 0 || c > 150) return null;
|
||||
return Math.round(c * 10) / 10;
|
||||
}
|
||||
|
||||
function isCpuName(name) {
|
||||
return /cpu|package|pkg|core|soc|k10temp|zenpower|tdie|tctl/i.test(name || '');
|
||||
}
|
||||
|
||||
export function readLinuxTemps(sysRoot) {
|
||||
const readings = [];
|
||||
const thermal = path.join(sysRoot, 'sys/class/thermal');
|
||||
for (const name of safeReaddir(thermal)) {
|
||||
if (!name.startsWith('thermal_zone')) continue;
|
||||
const dir = path.join(thermal, name);
|
||||
const celsius = toC(readNum(path.join(dir, 'temp')));
|
||||
if (celsius == null) continue;
|
||||
readings.push({ source: 'thermal', name: readText(path.join(dir, 'type'))?.trim() || name, celsius });
|
||||
}
|
||||
const hwmon = path.join(sysRoot, 'sys/class/hwmon');
|
||||
for (const name of safeReaddir(hwmon)) {
|
||||
const dir = path.join(hwmon, name);
|
||||
const chip = readText(path.join(dir, 'name'))?.trim() || name;
|
||||
for (const file of safeReaddir(dir)) {
|
||||
const m = /^temp(\d+)_input$/.exec(file);
|
||||
if (!m) continue;
|
||||
const celsius = toC(readNum(path.join(dir, file)));
|
||||
if (celsius == null) continue;
|
||||
const label = readText(path.join(dir, `temp${m[1]}_label`))?.trim();
|
||||
readings.push({ source: 'hwmon', name: label ? `${chip} ${label}` : chip, celsius });
|
||||
}
|
||||
}
|
||||
const cpuish = readings.filter((r) => isCpuName(r.name));
|
||||
const rest = readings.filter((r) => !isCpuName(r.name));
|
||||
const kept = [...cpuish, ...rest].slice(0, 12);
|
||||
return kept.length ? { status: 'ok', readings: kept } : { status: 'unavailable', readings: [] };
|
||||
}
|
||||
|
||||
function minutesBetween(now, full, rate) {
|
||||
if (now == null || rate == null || rate <= 0) return null;
|
||||
return Math.max(0, Math.round((Math.abs(full == null ? now : full - now) / rate) * 60));
|
||||
}
|
||||
|
||||
export function readLinuxBattery(sysRoot) {
|
||||
const root = path.join(sysRoot, 'sys/class/power_supply');
|
||||
const names = safeReaddir(root).filter((n) => /^(BAT|battery)/i.test(n)).sort();
|
||||
const parsed = [];
|
||||
for (const name of names) {
|
||||
const dir = path.join(root, name);
|
||||
const status = (readText(path.join(dir, 'status')) || '').trim().toLowerCase();
|
||||
const state = status === 'charging' ? 'charging'
|
||||
: status === 'discharging' ? 'discharging'
|
||||
: status === 'full' ? 'full'
|
||||
: status === 'not charging' ? 'not-charging'
|
||||
: 'unavailable';
|
||||
let percent = readNum(path.join(dir, 'capacity'));
|
||||
const energy = readNum(path.join(dir, 'energy_now'));
|
||||
const energyFull = readNum(path.join(dir, 'energy_full'));
|
||||
const power = readNum(path.join(dir, 'power_now'));
|
||||
const charge = readNum(path.join(dir, 'charge_now'));
|
||||
const chargeFull = readNum(path.join(dir, 'charge_full'));
|
||||
const current = readNum(path.join(dir, 'current_now'));
|
||||
if (percent == null && energy != null && energyFull > 0) percent = Math.round((100 * energy) / energyFull);
|
||||
if (percent == null && charge != null && chargeFull > 0) percent = Math.round((100 * charge) / chargeFull);
|
||||
if (percent == null && state === 'unavailable') continue;
|
||||
let minutes = null;
|
||||
if (state === 'discharging') minutes = energy != null ? minutesBetween(energy, null, power) : minutesBetween(charge, null, current);
|
||||
else if (state === 'charging') minutes = energy != null ? minutesBetween(energy, energyFull, power) : minutesBetween(charge, chargeFull, current);
|
||||
parsed.push({ percent, state, minutesRemaining: minutes });
|
||||
}
|
||||
if (!parsed.length) return emptyBattery();
|
||||
const pick = parsed.find((b) => b.state === 'discharging') || parsed[0];
|
||||
return { present: true, count: parsed.length, ...pick };
|
||||
}
|
||||
|
||||
function sh(cmd, args, timeout = 2500) {
|
||||
try {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout });
|
||||
if (r.status !== 0 || r.error) return null;
|
||||
return r.stdout || '';
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function pause(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
const r = spawnSync('sleep', [(n / 1000).toFixed(3)], { timeout: n + 2000 });
|
||||
if (r.error || r.status !== 0) {
|
||||
const end = Date.now() + n;
|
||||
while (Date.now() < end) { /* sleep(1) missing */ }
|
||||
}
|
||||
}
|
||||
|
||||
function pageSize() {
|
||||
const text = sh('getconf', ['PAGE_SIZE'], 1000);
|
||||
const n = Number(String(text || '').trim());
|
||||
return Number.isFinite(n) && n > 0 ? n : 4096;
|
||||
}
|
||||
|
||||
function userHz() {
|
||||
const text = sh('getconf', ['CLK_TCK'], 1000);
|
||||
const n = Number(String(text || '').trim());
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
}
|
||||
|
||||
function existingPath(target) {
|
||||
let dir = target;
|
||||
while (dir) {
|
||||
try { if (fs.existsSync(dir)) return dir; } catch { return null; }
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dfLive(target, platform) {
|
||||
const gnu = sh('df', ['-B1', '-P', target], 3000);
|
||||
if (gnu && /1B-blocks|1-blocks/.test(gnu)) return parseDf(gnu);
|
||||
const bsd = sh('df', ['-k', '-P', target], 3000);
|
||||
if (bsd) {
|
||||
const parsed = parseDf(bsd);
|
||||
if (parsed) {
|
||||
const scale = /512-blocks/.test(bsd) ? 512 : 1024;
|
||||
return {
|
||||
mount: parsed.mount,
|
||||
totalBytes: parsed.totalBytes * scale,
|
||||
usedBytes: parsed.usedBytes * scale,
|
||||
freeBytes: parsed.freeBytes * scale,
|
||||
freePct: parsed.freePct,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (gnu) return parseDf(gnu);
|
||||
if (platform === 'win32') return winDisk(target);
|
||||
return null;
|
||||
}
|
||||
|
||||
function winDisk(target) {
|
||||
const script = `$p=${JSON.stringify(target)}; $q=Split-Path -Qualifier $p; if (-not $q) { $q='C:' }; $d=New-Object System.IO.DriveInfo($q); if (-not $d.IsReady) { exit 1 }; Write-Output ($d.TotalSize.ToString()+' '+($d.TotalSize-$d.AvailableFreeSpace).ToString()+' '+$d.AvailableFreeSpace.ToString()+' '+$d.Name)`;
|
||||
const text = sh('powershell', ['-NoProfile', '-Command', script], 4000);
|
||||
if (!text) return null;
|
||||
const parts = text.trim().split(/\s+/);
|
||||
if (parts.length < 4) return null;
|
||||
const total = Number(parts[0]);
|
||||
const used = Number(parts[1]);
|
||||
const free = Number(parts[2]);
|
||||
if (![total, used, free].every(Number.isFinite)) return null;
|
||||
return { mount: parts.slice(3).join(' '), totalBytes: total, usedBytes: used, freeBytes: free, freePct: total ? Math.round((1000 * free) / total) / 10 : null };
|
||||
}
|
||||
|
||||
function detectWsl(sysRoot, opt) {
|
||||
if (opt.wsl != null) return Boolean(opt.wsl);
|
||||
const version = readText(path.join(sysRoot, 'proc/version'));
|
||||
if (version) return /microsoft|wsl/i.test(version);
|
||||
return Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
||||
}
|
||||
|
||||
function diskTargets(c) {
|
||||
return [
|
||||
['repo', c?.repo || '/'],
|
||||
['worktree', c?.worktreeRoot || c?.repo || '/'],
|
||||
['state', c?.stateDir || '/'],
|
||||
['tmp', os.tmpdir()],
|
||||
];
|
||||
}
|
||||
|
||||
function readDisks(c, platform, opt) {
|
||||
return diskTargets(c).map(([label, requested]) => {
|
||||
const parsed = opt.df
|
||||
? opt.df(requested)
|
||||
: dfLive(existingPath(requested) || requested, platform);
|
||||
if (!parsed || parsed.freeBytes == null) {
|
||||
return { label, path: requested, mount: null, totalBytes: null, usedBytes: null, freeBytes: null, freePct: null, status: 'unavailable' };
|
||||
}
|
||||
return { label, path: requested, status: 'ok', mount: parsed.mount ?? null, totalBytes: parsed.totalBytes, usedBytes: parsed.usedBytes, freeBytes: parsed.freeBytes, freePct: parsed.freePct };
|
||||
});
|
||||
}
|
||||
|
||||
function readLinuxMemory(sysRoot) {
|
||||
return withMemoryPct(parseMeminfo(readText(path.join(sysRoot, 'proc/meminfo')) || ''));
|
||||
}
|
||||
|
||||
function readGenericMemory() {
|
||||
const total = os.totalmem();
|
||||
const free = os.freemem();
|
||||
if (!total) return emptyMemory();
|
||||
return withMemoryPct({
|
||||
totalBytes: total,
|
||||
availableBytes: free,
|
||||
freeBytes: free,
|
||||
usedBytes: Math.max(0, total - free),
|
||||
swapTotalBytes: null,
|
||||
swapFreeBytes: null,
|
||||
swapUsedBytes: null,
|
||||
});
|
||||
}
|
||||
|
||||
function readDarwinMemory() {
|
||||
const total = Number(String(sh('sysctl', ['-n', 'hw.memsize']) || '').trim());
|
||||
const vm = parseVmStat(sh('vm_stat', []) || '');
|
||||
const swap = parseSwapUsage(sh('sysctl', ['-n', 'vm.swapusage']) || '');
|
||||
if (!Number.isFinite(total) || total <= 0) return readGenericMemory();
|
||||
const page = vm.pageSize || 4096;
|
||||
const availablePages = (vm.pages.free || 0) + (vm.pages.inactive || 0) + (vm.pages.speculative || 0);
|
||||
const available = Math.min(total, availablePages * page);
|
||||
return withMemoryPct({
|
||||
totalBytes: total,
|
||||
availableBytes: available,
|
||||
freeBytes: (vm.pages.free || 0) * page,
|
||||
usedBytes: Math.max(0, total - available),
|
||||
swapTotalBytes: swap.totalBytes,
|
||||
swapFreeBytes: swap.freeBytes,
|
||||
swapUsedBytes: swap.usedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function readDarwinBattery() {
|
||||
const text = sh('pmset', ['-g', 'batt'], 3000);
|
||||
if (!text) return emptyBattery();
|
||||
return parsePmset(text);
|
||||
}
|
||||
|
||||
function readWindowsBattery() {
|
||||
const text = sh('powershell', ['-NoProfile', '-Command', '(Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1 EstimatedChargeRemaining,BatteryStatus | ConvertTo-Json -Compress)'], 4000);
|
||||
if (!text || !text.trim() || text.trim() === 'null') return emptyBattery();
|
||||
try {
|
||||
const row = JSON.parse(text);
|
||||
if (!row || row.EstimatedChargeRemaining == null) return emptyBattery();
|
||||
const code = Number(row.BatteryStatus);
|
||||
const state = [6, 7, 8, 9].includes(code) ? 'charging'
|
||||
: code === 1 || code === 4 || code === 5 ? 'discharging'
|
||||
: code === 2 || code === 3 ? 'full'
|
||||
: 'unavailable';
|
||||
return { present: true, percent: Number(row.EstimatedChargeRemaining), state, minutesRemaining: null };
|
||||
} catch { return emptyBattery(); }
|
||||
}
|
||||
|
||||
function linuxSample(sysRoot, opt) {
|
||||
if (opt.statTexts || opt.procSamples) {
|
||||
return {
|
||||
usagePct: opt.statTexts ? cpuUsagePct(parseCpuStat(opt.statTexts[0]), parseCpuStat(opt.statTexts[1])) : null,
|
||||
before: opt.procSamples ? opt.procSamples[0] : [],
|
||||
after: opt.procSamples ? opt.procSamples[1] : [],
|
||||
elapsedMs: opt.elapsedMs ?? 1000,
|
||||
};
|
||||
}
|
||||
if (opt.sampleMs === 0) return { usagePct: null, before: [], after: [], elapsedMs: 0 };
|
||||
const stat1 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const pages = pageSize();
|
||||
const before = readProcSnapshot(sysRoot, pages);
|
||||
const t0 = Date.now();
|
||||
pause(opt.sampleMs ?? 200);
|
||||
const stat2 = parseCpuStat(readText(path.join(sysRoot, 'proc/stat')) || '');
|
||||
const after = readProcSnapshot(sysRoot, pages);
|
||||
let usagePct = cpuUsagePct(stat1, stat2);
|
||||
if (usagePct == null) {
|
||||
const a = cpuTimes();
|
||||
pause(opt.sampleMs ?? 200);
|
||||
usagePct = cpuUsagePct(a, cpuTimes());
|
||||
}
|
||||
return { usagePct, before, after, elapsedMs: Math.max(1, Date.now() - t0) };
|
||||
}
|
||||
|
||||
function readLoad(platform, sysRoot) {
|
||||
if (platform === 'linux') {
|
||||
const parsed = parseLoadavg(readText(path.join(sysRoot, 'proc/loadavg')) || '');
|
||||
if (parsed) return parsed;
|
||||
const live = os.loadavg();
|
||||
return live.some((n) => n > 0) ? live : [null, null, null];
|
||||
}
|
||||
if (platform === 'win32') return [null, null, null];
|
||||
return os.loadavg();
|
||||
}
|
||||
|
||||
function listPanes(c, opt) {
|
||||
if (opt.panes) return opt.panes;
|
||||
const session = `fleet-${c?.project || 'fleet'}`.replace(/[.:]/g, '-');
|
||||
return (c?.areas || []).map((area) => {
|
||||
const r = sh('tmux', ['list-panes', '-t', `${session}:${area.id}`, '-F', '#{pane_pid}'], 2000);
|
||||
const pid = Number(String(r || '').trim().split(/\s+/)[0]);
|
||||
return { id: area.id, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
|
||||
});
|
||||
}
|
||||
|
||||
function areasFromTree(panes, before, after, elapsedMs, hz) {
|
||||
return (panes || []).map((pane) => {
|
||||
if (!pane.pid) return { id: pane.id, found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { id: pane.id, ...treeUse(before, after, pane.pid, elapsedMs, hz) };
|
||||
});
|
||||
}
|
||||
|
||||
function areasFromPs(panes, rows) {
|
||||
return (panes || []).map((pane) => {
|
||||
if (!pane.pid) return { id: pane.id, found: false, cpuPct: null, rssBytes: null, pids: 0 };
|
||||
return { id: pane.id, ...sumTree(rows, pane.pid) };
|
||||
});
|
||||
}
|
||||
|
||||
function readPsAreas(panes, opt) {
|
||||
const text = opt.psText != null ? opt.psText : sh('ps', ['-ax', '-o', 'pid=,ppid=,rss=,%cpu='], 3000);
|
||||
if (!text) return (panes || []).map((pane) => ({ id: pane.id, found: Boolean(pane.pid), cpuPct: null, rssBytes: null, pids: pane.pid ? null : 0 }));
|
||||
return areasFromPs(panes, parsePs(text));
|
||||
}
|
||||
|
||||
function fmtBytes(bytes) {
|
||||
if (bytes == null) return 'unavailable';
|
||||
const gb = bytes / GB;
|
||||
if (gb >= 1) return `${Math.round(gb * 10) / 10} GB`;
|
||||
return `${Math.round(bytes / (1024 ** 2))} MB`;
|
||||
}
|
||||
|
||||
function judge(snap, limits) {
|
||||
const crossed = [];
|
||||
const mem = snap.memory;
|
||||
if (mem?.status === 'ok' && mem.availableBytes != null && mem.totalBytes > 0) {
|
||||
const pct = (100 * mem.availableBytes) / mem.totalBytes;
|
||||
if (pct < limits.memoryAvailablePct) {
|
||||
crossed.push({ id: 'memory', words: `memory available ${Math.round(pct)}% (${fmtBytes(mem.availableBytes)} of ${fmtBytes(mem.totalBytes)}), under ${limits.memoryAvailablePct}%` });
|
||||
}
|
||||
}
|
||||
if (mem?.swapTotalBytes > 0 && mem.swapUsedBytes != null) {
|
||||
const pct = (100 * mem.swapUsedBytes) / mem.swapTotalBytes;
|
||||
if (pct >= limits.swapUsedPct) {
|
||||
crossed.push({ id: 'swap', words: `swap ${Math.round(pct)}% used, at or over ${limits.swapUsedPct}%` });
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const disk of snap.disks || []) {
|
||||
if (disk.status !== 'ok' || disk.freeBytes == null) continue;
|
||||
const key = disk.mount || disk.path;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const freeGb = disk.freeBytes / GB;
|
||||
const lowPct = disk.freePct != null && disk.freePct < limits.diskFreePct;
|
||||
const lowGb = freeGb < limits.diskFreeGb;
|
||||
if (!lowPct && !lowGb) continue;
|
||||
const labels = snap.disks.filter((row) => row.status === 'ok' && (row.mount || row.path) === key).map((row) => row.label).join(', ');
|
||||
const pct = disk.freePct == null ? '' : ` (${Math.round(disk.freePct)}%)`;
|
||||
crossed.push({ id: `disk:${key}`, words: `${labels} on ${key}: ${fmtBytes(disk.freeBytes)} free${pct}, under ${limits.diskFreeGb} GB or ${limits.diskFreePct}%` });
|
||||
}
|
||||
const readings = snap.temps?.readings || [];
|
||||
const cpuish = readings.filter((r) => isCpuName(r.name));
|
||||
const pool = cpuish.length ? cpuish : readings;
|
||||
if (pool.length) {
|
||||
const hot = pool.reduce((a, b) => (b.celsius > a.celsius ? b : a));
|
||||
if (hot.celsius >= limits.cpuTempC) {
|
||||
crossed.push({ id: 'temp', words: `${hot.name} ${hot.celsius}°C, at or over ${limits.cpuTempC}°C` });
|
||||
}
|
||||
}
|
||||
const battery = snap.battery;
|
||||
if (battery?.present && battery.percent != null && battery.percent <= limits.batteryPct && battery.state !== 'charging' && battery.state !== 'full') {
|
||||
const how = battery.state === 'discharging' ? ' and discharging' : '';
|
||||
crossed.push({ id: 'battery', words: `battery ${battery.percent}%${how}, at or under ${limits.batteryPct}%` });
|
||||
} else if (battery?.present && battery.state === 'discharging' && battery.minutesRemaining != null && battery.minutesRemaining <= limits.batteryMinutes) {
|
||||
crossed.push({ id: 'battery', words: `battery discharging with about ${battery.minutesRemaining} min left, at or under ${limits.batteryMinutes} min` });
|
||||
}
|
||||
if (snap.cpu?.load1 != null && snap.cpu.cores) {
|
||||
const per = snap.cpu.load1 / snap.cpu.cores;
|
||||
if (per >= limits.loadPerCore) {
|
||||
crossed.push({ id: 'load', words: `load ${snap.cpu.load1} on ${snap.cpu.cores} cores (${Math.round(per * 10) / 10} per core), at or over ${limits.loadPerCore}` });
|
||||
}
|
||||
}
|
||||
return crossed;
|
||||
}
|
||||
|
||||
function verdictOf(crossed, battery) {
|
||||
const onBattery = battery?.present && battery.state === 'discharging';
|
||||
if (!crossed.length && onBattery) {
|
||||
const left = battery.minutesRemaining == null ? 'time left unavailable' : `about ${battery.minutesRemaining} min left`;
|
||||
const pct = battery.percent == null ? 'unknown %' : `${battery.percent}%`;
|
||||
return `ok: on battery at ${pct}, ${left}; nothing crossed the suggested limits`;
|
||||
}
|
||||
if (!crossed.length) return 'ok: nothing crossed the suggested limits';
|
||||
return `tight: ${crossed.map((row) => row.words).join('; ')}`;
|
||||
}
|
||||
|
||||
export function formatHealth(snap) {
|
||||
const lines = [snap.verdict];
|
||||
const cpu = snap.cpu || {};
|
||||
const load = cpu.load1 == null ? 'load unavailable' : `load ${cpu.load1} ${cpu.load5} ${cpu.load15}`;
|
||||
const usage = cpu.usagePct == null ? 'usage unavailable' : `usage ${cpu.usagePct}%`;
|
||||
lines.push(`cpu ${load} ${cpu.cores ?? '?'} cores ${usage}${snap.wsl ? ' wsl' : ''}`);
|
||||
const mem = snap.memory;
|
||||
if (!mem || mem.status !== 'ok') lines.push('mem unavailable');
|
||||
else {
|
||||
const swap = mem.swapTotalBytes == null ? 'swap unavailable' : mem.swapTotalBytes === 0 ? 'swap none' : `swap ${fmtBytes(mem.swapUsedBytes)} / ${fmtBytes(mem.swapTotalBytes)}`;
|
||||
lines.push(`mem ${fmtBytes(mem.usedBytes)} used / ${fmtBytes(mem.totalBytes)} available ${fmtBytes(mem.availableBytes)} (${mem.availablePct ?? '?'}%) ${swap}`);
|
||||
}
|
||||
for (const disk of snap.disks || []) {
|
||||
if (disk.status !== 'ok') lines.push(`disk ${disk.label} unavailable ${disk.path}`);
|
||||
else lines.push(`disk ${disk.label} ${disk.mount} ${fmtBytes(disk.freeBytes)} free (${disk.freePct}%) ${disk.path}`);
|
||||
}
|
||||
if (!snap.temps || snap.temps.status !== 'ok') lines.push('temp unavailable');
|
||||
else lines.push(`temp ${snap.temps.readings.map((r) => `${r.name} ${r.celsius}°C`).join(' ')}`);
|
||||
const battery = snap.battery || emptyBattery();
|
||||
if (!battery.present) lines.push('battery none');
|
||||
else {
|
||||
const left = battery.minutesRemaining == null ? 'time unavailable' : `${battery.minutesRemaining} min left`;
|
||||
lines.push(`battery ${battery.percent ?? '?'}% ${battery.state} ${left}`);
|
||||
}
|
||||
if (!snap.areas?.length) lines.push('areas none');
|
||||
for (const area of snap.areas || []) {
|
||||
if (!area.found) lines.push(`area ${area.id} window not found`);
|
||||
else if (area.cpuPct == null && area.rssBytes == null) lines.push(`area ${area.id} unavailable`);
|
||||
else lines.push(`area ${area.id} cpu ${area.cpuPct ?? '?'}% rss ${fmtBytes(area.rssBytes)} ${area.pids ?? '?'} procs`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function fleetHealth(c = {}, opt = {}) {
|
||||
const platform = opt.platform || process.platform;
|
||||
const sysRoot = opt.sysRoot || '/';
|
||||
const limits = healthThresholds(c.health);
|
||||
const wsl = platform === 'linux' ? detectWsl(sysRoot, opt) : false;
|
||||
const cores = opt.cores ?? (os.cpus()?.length || null);
|
||||
const needsLinuxSample = platform === 'linux' && (opt.cpu == null || opt.areas == null);
|
||||
const sampled = needsLinuxSample ? linuxSample(sysRoot, opt) : null;
|
||||
const load = readLoad(platform, sysRoot);
|
||||
const cpu = opt.cpu ?? {
|
||||
load1: load[0],
|
||||
load5: load[1],
|
||||
load15: load[2],
|
||||
cores,
|
||||
usagePct: platform === 'linux'
|
||||
? sampled?.usagePct ?? null
|
||||
: (opt.sampleMs === 0 ? null : cpuUsagePct(cpuTimes(), (pause(opt.sampleMs ?? 200), cpuTimes()))),
|
||||
};
|
||||
const memory = opt.memory
|
||||
? withMemoryPct(opt.memory.status === 'unavailable' ? null : opt.memory)
|
||||
: platform === 'linux' ? readLinuxMemory(sysRoot)
|
||||
: platform === 'darwin' ? readDarwinMemory()
|
||||
: readGenericMemory();
|
||||
const disks = opt.disks ?? readDisks(c, platform, opt);
|
||||
const temps = opt.temps ?? (platform === 'linux' ? readLinuxTemps(sysRoot) : { status: 'unavailable', readings: [] });
|
||||
const battery = opt.battery ?? (
|
||||
platform === 'linux' ? readLinuxBattery(sysRoot)
|
||||
: platform === 'darwin' ? readDarwinBattery()
|
||||
: platform === 'win32' ? readWindowsBattery()
|
||||
: emptyBattery()
|
||||
);
|
||||
let areas = opt.areas;
|
||||
if (areas == null) {
|
||||
const panes = listPanes(c, opt);
|
||||
if (platform === 'linux' && sampled && (sampled.before.length || sampled.after.length || opt.procSamples || opt.sampleMs === 0)) {
|
||||
areas = areasFromTree(panes, sampled.before, sampled.after, sampled.elapsedMs, opt.hz ?? userHz());
|
||||
} else if (platform === 'linux' && opt.sampleMs !== 0) {
|
||||
areas = areasFromTree(panes, sampled?.before || [], sampled?.after || [], sampled?.elapsedMs || 0, opt.hz ?? userHz());
|
||||
if (areas.every((a) => !a.found) && panes.some((p) => p.pid) && !opt.procSamples) areas = readPsAreas(panes, opt);
|
||||
} else {
|
||||
areas = readPsAreas(panes, opt);
|
||||
}
|
||||
}
|
||||
const snap = {
|
||||
at: opt.at || new Date().toISOString(),
|
||||
platform,
|
||||
wsl,
|
||||
cpu,
|
||||
memory,
|
||||
disks,
|
||||
temps,
|
||||
battery,
|
||||
areas,
|
||||
thresholds: limits,
|
||||
};
|
||||
snap.crossed = judge(snap, limits);
|
||||
snap.verdict = verdictOf(snap.crossed, snap.battery);
|
||||
return snap;
|
||||
}
|
||||
+233
-27
@@ -2,9 +2,10 @@
|
||||
// both call these. Do not copy this logic into a plugin or a second server.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { KIT, area as getArea, die, pidAlive, tryGit, git, mainRef, writeAtomic, today, landerIsUp, matchesAny, startLander, runTimedSync } from '../lib.mjs';
|
||||
import { send as mailboxSendFile, read as mailboxReadDir, archive as mailboxArchiveDir } from '../../adapters/mailbox.mjs';
|
||||
import { appendWakeLog, priorWakes } from './wake-log.mjs';
|
||||
|
||||
const STATUSES = ['open', 'in-progress', 'blocked', 'done', 'deferred'];
|
||||
const SPECIAL = ['owner', 'scheduler', 'interpreter'];
|
||||
@@ -119,13 +120,19 @@ function knownParty(c, id) {
|
||||
getArea(c, id);
|
||||
}
|
||||
|
||||
export function sendMailbox(c, { to, from, body, subject = '' } = {}) {
|
||||
export function sendMailbox(c, { to, from, body, subject = '', trigger = 'send' } = {}) {
|
||||
if (!from) die('--from <your id> required');
|
||||
knownParty(c, from);
|
||||
if (!body || !String(body).trim()) die('empty message');
|
||||
const targets = to === 'all' ? c.areas.map((a) => a.id).filter((id) => id !== from) : [to];
|
||||
const files = targets.map((t) => { knownParty(c, t); return mailboxSendFile(c.mailbox.dir, t, from, body, subject ?? ''); });
|
||||
const wakes = targets.filter((t) => t !== from).map((t) => ({ to: t, ...nudgeMail(c, t, from) }));
|
||||
const files = [];
|
||||
const wakes = [];
|
||||
for (const t of targets) {
|
||||
knownParty(c, t);
|
||||
const file = mailboxSendFile(c.mailbox.dir, t, from, body, subject ?? '');
|
||||
files.push(file);
|
||||
if (t !== from) wakes.push({ to: t, ...nudgeMail(c, t, from, { trigger, file: path.basename(file) }) });
|
||||
}
|
||||
return Object.assign(files, { wakes });
|
||||
}
|
||||
|
||||
@@ -193,7 +200,7 @@ export function setupCommit(repo, { message = 'fleet init' } = {}) {
|
||||
export function tellDispatch(c, { body, from = 'interpreter', subject = '' } = {}) {
|
||||
const hub = c.areas.find((a) => a.role === 'dispatcher');
|
||||
if (!hub) die('no session manager (role dispatcher)');
|
||||
const files = sendMailbox(c, { to: hub.id, from, body, subject });
|
||||
const files = sendMailbox(c, { to: hub.id, from, body, subject, trigger: 'tell' });
|
||||
const wake = (files.wakes || []).find((w) => w.to === hub.id) || { submitted: false, reason: 'no-window' };
|
||||
const status = fleetStatus(c);
|
||||
return { files: [...files], to: hub.id, from, nudged: Boolean(wake.submitted), wake, recovered: status.recovered };
|
||||
@@ -203,11 +210,14 @@ export function hearInterpreter(c) {
|
||||
return readMailbox(c, 'interpreter');
|
||||
}
|
||||
|
||||
const NUDGE_PAUSE_MS = 200;
|
||||
const NUDGE_SETTLE_MS = 150;
|
||||
const NUDGE_RETRY_MS = 400;
|
||||
// Backoff before each Enter. Codex's paste window outlasts a single short pause.
|
||||
// 250 + 800 + 2000, plus a short settle after each Enter, stays under 4s.
|
||||
const ENTER_DELAYS_MS = [250, 800, 2000];
|
||||
const ENTER_SETTLE_MS = 150;
|
||||
const IDLE_AFTER_MS = 3000;
|
||||
const RECOVER_AFTER_MS = 120000;
|
||||
const WAKE_WAIT_POLL_MS = 3000;
|
||||
const WAKE_WAIT_MAX_MS = 600000;
|
||||
const BUSY_RE = /esc to interrupt|esc to cancel|ctrl\+c to (?:interrupt|cancel)/i;
|
||||
|
||||
export function nudgeLine(from) {
|
||||
@@ -243,28 +253,219 @@ function stillInInput(target, line) {
|
||||
return cursorRow(target, capturePane(target)).includes(line);
|
||||
}
|
||||
|
||||
function inspectPane(target) {
|
||||
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { found: false, dead: false, interrupt: false, idleSec: null, pane: '' };
|
||||
const info = tmuxText(['display-message', '-p', '-t', target, '#{pane_dead} #{window_activity}']);
|
||||
const match = String(info.stdout || '').trim().match(/^([01])\s+(\d+)$/);
|
||||
const pane = capturePane(target);
|
||||
let idleSec = null;
|
||||
let dead = false;
|
||||
if (match) {
|
||||
dead = match[1] === '1';
|
||||
idleSec = Math.max(0, Math.round(Date.now() / 1000 - Number(match[2])));
|
||||
}
|
||||
return { found: true, dead, interrupt: BUSY_RE.test(pane), idleSec, pane };
|
||||
}
|
||||
|
||||
function paneTail(pane) {
|
||||
const rows = String(pane).split('\n').map((s) => s.trim()).filter(Boolean).slice(-4);
|
||||
return rows.map((s) => s.slice(0, 120)).join(' | ').slice(0, 400);
|
||||
}
|
||||
|
||||
function snapBefore(before) {
|
||||
return { found: Boolean(before?.found), dead: Boolean(before?.dead), interrupt: Boolean(before?.interrupt), idleSec: before?.idleSec ?? null };
|
||||
}
|
||||
|
||||
/** Window names in the fleet session, so a miss says what was there. */
|
||||
function sessionWindows(c) {
|
||||
const session = `fleet-${c.project}`.replace(/[.:]/g, '-');
|
||||
const has = tmuxText(['has-session', '-t', session]);
|
||||
const listed = tmuxText(['list-windows', '-t', session, '-F', '#{window_index} #{window_name}']);
|
||||
return {
|
||||
session,
|
||||
sessionUp: has.status === 0,
|
||||
windows: listed.status === 0 ? listed.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [],
|
||||
listError: listed.status === 0 ? '' : String(listed.stderr || has.stderr || '').trim().slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
function wakeLockPath(c, areaId) {
|
||||
return path.join(c.stateDir, 'wake-wait', `${areaId}.lock`);
|
||||
}
|
||||
|
||||
function claimWakeLock(lock) {
|
||||
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
||||
const body = `${JSON.stringify({ pid: process.pid, starting: true, t: new Date().toISOString() })}\n`;
|
||||
const create = () => {
|
||||
const fd = fs.openSync(lock, 'wx');
|
||||
fs.writeSync(fd, body);
|
||||
fs.closeSync(fd);
|
||||
};
|
||||
try {
|
||||
create();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.code !== 'EEXIST') return false;
|
||||
let cur = {};
|
||||
try { cur = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch { /* empty or torn */ }
|
||||
if (cur.pid && pidAlive(cur.pid)) return false;
|
||||
try { fs.unlinkSync(lock); } catch { return false; }
|
||||
try { create(); return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
function handDeferred(c, areaId, from, file) {
|
||||
const lock = wakeLockPath(c, areaId);
|
||||
if (!claimWakeLock(lock)) return { started: false };
|
||||
const poll = Number(c.wakeWait?.pollMs) > 0 ? Number(c.wakeWait.pollMs) : WAKE_WAIT_POLL_MS;
|
||||
const max = Number(c.wakeWait?.maxMs) > 0 ? Number(c.wakeWait.maxMs) : WAKE_WAIT_MAX_MS;
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(KIT, 'bin/core/wake-wait.mjs'),
|
||||
'--config', c.file,
|
||||
'--area', areaId,
|
||||
'--from', from,
|
||||
'--file', file || '',
|
||||
'--poll-ms', String(poll),
|
||||
'--max-ms', String(max),
|
||||
], { detached: true, stdio: 'ignore' });
|
||||
if (!child.pid) {
|
||||
try { fs.unlinkSync(lock); } catch { /* already gone */ }
|
||||
return { started: false };
|
||||
}
|
||||
fs.writeFileSync(lock, `${JSON.stringify({ pid: child.pid, area: areaId, file: file || null, from, t: new Date().toISOString() })}\n`);
|
||||
child.unref();
|
||||
return { started: true, pid: child.pid };
|
||||
}
|
||||
|
||||
function releaseWakeLock(c, areaId) {
|
||||
try { fs.unlinkSync(wakeLockPath(c, areaId)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the wake line and press Enter until the cursor row no longer holds it.
|
||||
* One literal send-keys -l, then Enter on its own. The fake pane does not
|
||||
* treat chunked keys differently, so the backoff is what survives a long paste.
|
||||
*/
|
||||
function submitWake(c, areaId, from, meta, before) {
|
||||
const target = tmuxTarget(c, areaId);
|
||||
const line = nudgeLine(from);
|
||||
const record = {
|
||||
t: new Date().toISOString(),
|
||||
area: areaId,
|
||||
window: target,
|
||||
file: meta.file || null,
|
||||
trigger: meta.trigger || 'send',
|
||||
before: snapBefore(before),
|
||||
};
|
||||
const finish = (fields) => {
|
||||
const { line: _line, reason, ...logged } = fields;
|
||||
appendWakeLog(c, { ...record, ...logged, tail: paneTail(capturePane(target)) });
|
||||
return { submitted: logged.outcome === 'submitted', reason, line };
|
||||
};
|
||||
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) {
|
||||
return finish({ action: 'send-failed', enterAttempts: 0, enterDelays: [], outcome: 'send-failed', reason: 'send-failed', line });
|
||||
}
|
||||
const enterDelays = [];
|
||||
let enterAttempts = 0;
|
||||
for (const delay of ENTER_DELAYS_MS) {
|
||||
sleepMs(delay);
|
||||
enterDelays.push(delay);
|
||||
tmuxText(['send-keys', '-t', target, 'Enter']);
|
||||
enterAttempts += 1;
|
||||
sleepMs(ENTER_SETTLE_MS);
|
||||
if (!stillInInput(target, line)) {
|
||||
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'submitted', reason: 'submitted', line });
|
||||
}
|
||||
}
|
||||
return finish({ action: 'typed', enterAttempts, enterDelays, outcome: 'swallowed', reason: 'not-submitted', line });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake an open tmux window with a short line, not the mail body.
|
||||
* Codex and Claude treat a fast burst as a paste and swallow Enter, so the
|
||||
* line is typed, then Enter is sent after a pause, outside that burst.
|
||||
* If the cursor row still holds the line, Enter is sent once more.
|
||||
* A pane that shows an interrupt hint is left alone; a later status check can nudge it.
|
||||
* Enter is retried with backoff until the line leaves the composer.
|
||||
* A pane showing an interrupt hint is handed to one detached waiter.
|
||||
*/
|
||||
export function nudgeMail(c, areaId, from) {
|
||||
export function nudgeMail(c, areaId, from, meta = {}) {
|
||||
const target = tmuxTarget(c, areaId);
|
||||
if (tmuxText(['list-panes', '-t', target]).status !== 0) return { submitted: false, reason: 'no-window' };
|
||||
const before = inspectPane(target);
|
||||
const record = {
|
||||
t: new Date().toISOString(),
|
||||
area: areaId,
|
||||
window: target,
|
||||
file: meta.file || null,
|
||||
trigger: meta.trigger || 'send',
|
||||
before: snapBefore(before),
|
||||
};
|
||||
const finish = (fields) => {
|
||||
const { line, reason, ...logged } = fields;
|
||||
const pane = logged.action === 'no-window' ? '' : capturePane(target);
|
||||
const where = logged.action === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
|
||||
appendWakeLog(c, { ...record, ...logged, ...where, tail: paneTail(pane) });
|
||||
return { submitted: logged.outcome === 'submitted', reason, ...(line ? { line } : {}) };
|
||||
};
|
||||
if (!before.found) return finish({ action: 'no-window', enterAttempts: 0, enterDelays: [], outcome: 'no-window', reason: 'no-window' });
|
||||
const line = nudgeLine(from);
|
||||
if (BUSY_RE.test(capturePane(target))) return { submitted: false, reason: 'working', line };
|
||||
if (tmuxText(['send-keys', '-t', target, '-l', line]).status !== 0) return { submitted: false, reason: 'send-failed', line };
|
||||
sleepMs(NUDGE_PAUSE_MS);
|
||||
tmuxText(['send-keys', '-t', target, 'Enter']);
|
||||
sleepMs(NUDGE_SETTLE_MS);
|
||||
if (!stillInInput(target, line)) return { submitted: true, reason: 'submitted', line };
|
||||
sleepMs(NUDGE_RETRY_MS);
|
||||
tmuxText(['send-keys', '-t', target, 'Enter']);
|
||||
sleepMs(NUDGE_SETTLE_MS);
|
||||
const submitted = !stillInInput(target, line);
|
||||
return { submitted, reason: submitted ? 'submitted' : 'not-submitted', line };
|
||||
if (before.interrupt) {
|
||||
const hand = handDeferred(c, areaId, from, meta.file || '');
|
||||
return finish({
|
||||
action: 'deferred',
|
||||
outcome: hand.started ? 'deferred' : 'waiter-running',
|
||||
enterAttempts: 0,
|
||||
enterDelays: [],
|
||||
waiterPid: hand.pid || null,
|
||||
reason: 'working',
|
||||
line,
|
||||
});
|
||||
}
|
||||
return submitWake(c, areaId, from, meta, before);
|
||||
}
|
||||
|
||||
/** Poll until the pane is idle, the mail is read, or the window closes. One process per area. */
|
||||
export function runWakeWait(c, { area, from, file, pollMs, maxMs } = {}) {
|
||||
if (!area) die('wake-wait needs --area');
|
||||
const poll = Number(pollMs) > 0 ? Number(pollMs) : WAKE_WAIT_POLL_MS;
|
||||
const max = Number(maxMs) > 0 ? Number(maxMs) : WAKE_WAIT_MAX_MS;
|
||||
const target = tmuxTarget(c, area);
|
||||
const started = Date.now();
|
||||
const release = () => releaseWakeLock(c, area);
|
||||
const log = (fields) => {
|
||||
const where = fields.outcome === 'no-window' ? { lookup: target, ...sessionWindows(c) } : {};
|
||||
appendWakeLog(c, {
|
||||
t: new Date().toISOString(),
|
||||
area,
|
||||
window: target,
|
||||
file: file || null,
|
||||
trigger: 'deferred',
|
||||
...fields,
|
||||
...where,
|
||||
tail: fields.outcome === 'no-window' ? '' : paneTail(capturePane(target)),
|
||||
});
|
||||
release();
|
||||
};
|
||||
const unread = () => file && fs.existsSync(path.join(c.mailbox.dir, area, file));
|
||||
while (Date.now() - started < max) {
|
||||
const left = max - (Date.now() - started);
|
||||
if (left <= 0) break;
|
||||
sleepMs(Math.min(poll, left));
|
||||
if (file && !unread()) {
|
||||
return log({ action: 'already-read', outcome: 'already-read', enterAttempts: 0, enterDelays: [], before: { found: true, dead: false, interrupt: false, idleSec: null } });
|
||||
}
|
||||
const before = inspectPane(target);
|
||||
if (!before.found) {
|
||||
return log({ action: 'no-window', outcome: 'no-window', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
if (before.dead) {
|
||||
return log({ action: 'no-window', outcome: 'dead', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
if (before.interrupt) continue;
|
||||
try {
|
||||
return submitWake(c, area, from, { trigger: 'deferred', file: file || null }, before);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
const before = inspectPane(target);
|
||||
return log({ action: 'gave-up', outcome: 'timeout', enterAttempts: 0, enterDelays: [], before: snapBefore(before) });
|
||||
}
|
||||
|
||||
function unreadFiles(c, id) {
|
||||
@@ -325,7 +526,12 @@ export function fleetStatus(c, { recoverAfterMs = RECOVER_AFTER_MS, idleAfterMs
|
||||
const age = oldest ? Date.now() - oldest.mtimeMs : 0;
|
||||
const already = (state[a.id] || []).includes(oldest?.file);
|
||||
if (view.window === 'alive' && view.activity === 'idle' && oldest && age >= recoverAfterMs && !already) {
|
||||
const wake = nudgeMail(c, a.id, senderOf(oldest.path));
|
||||
const prior = priorWakes(c, a.id, oldest.file).slice(-5).map((p) => ({ t: p.t, trigger: p.trigger, action: p.action, outcome: p.outcome }));
|
||||
appendWakeLog(c, {
|
||||
t: new Date().toISOString(), kind: 'stale', area: a.id, window: tmuxTarget(c, a.id),
|
||||
file: oldest.file, ageSec: Math.round(age / 1000), prior,
|
||||
});
|
||||
const wake = nudgeMail(c, a.id, senderOf(oldest.path), { trigger: 'recovery', file: oldest.file });
|
||||
state[a.id] = [...(state[a.id] || []), oldest.file];
|
||||
stateDirty = true;
|
||||
row.recovered = true;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// One JSON line per wake attempt, in the fleet state dir (not the checkout).
|
||||
// The line records what the pane looked like and what the nudge did. It does
|
||||
// not record mail bodies. The file is capped so it cannot grow without bound.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeAtomic } from '../lib.mjs';
|
||||
|
||||
export const WAKE_LOG_MAX = 400;
|
||||
|
||||
export function wakeLogPath(c) {
|
||||
return path.join(c.stateDir, 'wake-log.jsonl');
|
||||
}
|
||||
|
||||
export function appendWakeLog(c, entry) {
|
||||
const file = wakeLogPath(c);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
let lines = [];
|
||||
try { lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean); } catch { /* new log */ }
|
||||
lines.push(JSON.stringify(entry));
|
||||
if (lines.length > WAKE_LOG_MAX) lines = lines.slice(lines.length - WAKE_LOG_MAX);
|
||||
writeAtomic(file, `${lines.join('\n')}\n`);
|
||||
}
|
||||
|
||||
export function priorWakes(c, area, file) {
|
||||
return readRaw(c).filter((e) => e && e.kind !== 'stale' && e.area === area && e.file === file);
|
||||
}
|
||||
|
||||
function readRaw(c) {
|
||||
try {
|
||||
return fs.readFileSync(wakeLogPath(c), 'utf8').split('\n').filter(Boolean).map((line) => {
|
||||
try { return JSON.parse(line); } catch { return null; }
|
||||
}).filter(Boolean);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Plain words for the failure a later reader should tell the user. */
|
||||
export function wakeFlag(entry) {
|
||||
if (!entry) return 'unknown';
|
||||
if (entry.kind === 'stale') {
|
||||
const last = Array.isArray(entry.prior) ? entry.prior[entry.prior.length - 1] : null;
|
||||
const age = entry.ageSec ?? '?';
|
||||
if (!last) return `still unread after ${age}s; no earlier wake`;
|
||||
if (last.outcome === 'swallowed') return `woke at ${last.t} (${last.trigger}), wake line still in the composer, still unread after ${age}s`;
|
||||
if (last.outcome === 'timeout') return `woke at ${last.t} (${last.trigger}), pane stayed busy until the waiter stopped, still unread after ${age}s`;
|
||||
if (last.outcome === 'deferred' || last.action === 'deferred') return `woke at ${last.t} (${last.trigger}), handed to a waiter because the pane was busy, still unread after ${age}s`;
|
||||
if (last.action === 'skipped-busy') return `woke at ${last.t} (${last.trigger}), skipped because an interrupt hint was showing, still unread after ${age}s`;
|
||||
if (last.action === 'no-window' || last.outcome === 'no-window') return `woke at ${last.t} (${last.trigger}), pane not found, still unread after ${age}s`;
|
||||
if (last.outcome === 'submitted') return `woke at ${last.t} (${last.trigger}), line left the composer, still unread after ${age}s`;
|
||||
return `woke at ${last.t} (${last.trigger}), outcome ${last.outcome}, still unread after ${age}s`;
|
||||
}
|
||||
if (entry.action === 'no-window' || entry.outcome === 'no-window') {
|
||||
const names = Array.isArray(entry.windows) && entry.windows.length ? entry.windows.join(', ') : 'none';
|
||||
const looked = entry.lookup || entry.window || 'unknown window';
|
||||
return `pane not found (looked up ${looked}; session windows: ${names})`;
|
||||
}
|
||||
if (entry.outcome === 'waiter-running') return 'a waiter is already polling this pane';
|
||||
if (entry.outcome === 'already-read') return 'mail was read before the pane went idle, so nothing was typed';
|
||||
if (entry.outcome === 'timeout') return 'pane stayed busy until the waiter stopped';
|
||||
if (entry.outcome === 'deferred') return 'handed to a waiter because an interrupt hint was showing';
|
||||
if (entry.action === 'skipped-busy') return 'skipped: interrupt hint was showing, so the pane looked mid-turn';
|
||||
if (entry.action === 'send-failed') return 'send-keys failed before Enter';
|
||||
if (entry.trigger === 'deferred' && entry.outcome === 'swallowed') return `waiter typed it; wake line still on the cursor row after ${entry.enterAttempts} Enter`;
|
||||
if (entry.outcome === 'swallowed') return `wake line still on the cursor row after ${entry.enterAttempts} Enter`;
|
||||
if (entry.trigger === 'deferred' && entry.outcome === 'submitted') return 'waiter typed the line and it left the composer';
|
||||
if (entry.outcome === 'submitted') {
|
||||
if (entry.before && entry.before.idleSec != null && entry.before.idleSec < 3 && !entry.before.interrupt) {
|
||||
return 'typed and the line left the composer while the pane was recently active';
|
||||
}
|
||||
return 'typed and the line left the composer';
|
||||
}
|
||||
return entry.outcome || entry.action || 'unknown';
|
||||
}
|
||||
|
||||
export function readWakeLog(c, { limit = 40 } = {}) {
|
||||
const all = readRaw(c);
|
||||
const entries = all.slice(-Math.max(1, Number(limit) || 40)).map((entry) => ({ ...entry, flag: wakeFlag(entry) }));
|
||||
return { path: wakeLogPath(c), entries };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// One detached waiter for a busy pane. Started by nudgeMail, not by hand.
|
||||
import { flag, loadConfig, runningAsMain } from '../lib.mjs';
|
||||
import { runWakeWait } from './ops.mjs';
|
||||
|
||||
if (runningAsMain(import.meta.url)) {
|
||||
const c = loadConfig();
|
||||
runWakeWait(c, {
|
||||
area: flag(process.argv, '--area'),
|
||||
from: flag(process.argv, '--from') || 'fleet',
|
||||
file: flag(process.argv, '--file') || '',
|
||||
pollMs: Number(flag(process.argv, '--poll-ms')) || 0,
|
||||
maxMs: Number(flag(process.argv, '--max-ms')) || 0,
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,8 @@
|
||||
// fleet doctor | landq | backlog | mailbox
|
||||
// the same tools as tools/fleet/bin/<name>.mjs
|
||||
// fleet status areas, run state (running / exited / failed / timeout / stopped), lander up or down
|
||||
// fleet wakes recent wake attempts, and plain words for a likely failure
|
||||
// fleet health [--json] one snapshot: CPU, memory, disk, temps, battery, per-area use
|
||||
// fleet prompt <id> print the composed system prompt for an area
|
||||
// fleet crontab PRINT the crontab lines for scheduled jobs (never installs)
|
||||
// fleet run-job <name> one headless run of docs/agents/schedules/<name>.md (sched.sh calls this)
|
||||
@@ -51,6 +53,8 @@ import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { KIT, loadConfig, loadAdapter, area as getArea, dispatcher, sessionName, flag, die, writeAtomic, tryGit, mainRef, today, runProcessGroup, runForeground, finishHeadless, withHandoff, applyHeadlessSession, assignLaunchSession, areaBudgetMin, readRun, writeRun, outcomeOfRun, pidAlive, killProcessGroup, landerIsUp, landerPid, startLander, writableRoots } from './lib.mjs';
|
||||
import { commitWork, setupCommit, tellDispatch, hearInterpreter, fleetStatus } from './core/ops.mjs';
|
||||
import { readWakeLog } from './core/wake-log.mjs';
|
||||
import { fleetHealth, formatHealth } from './core/health.mjs';
|
||||
import { fleetUpdate, recordInstalled, isTemplatePath } from './core/update.mjs';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -99,7 +103,7 @@ function composePrompt(a, adapter) {
|
||||
'area.neighbours': a.neighbours.join(', ') || '(none listed)',
|
||||
'area.branch': a.branch, 'area.worktree': a.worktree, mainBranch: c.mainBranch, peers,
|
||||
dispatcher: dispatcher(c)?.id ?? '(none)', gate: c.gates.land ?? '(no gate configured)',
|
||||
quietHours: c.clock.quietHours.join('–'), messaging: adapter.messaging?.instructions ?? '',
|
||||
quietHours: c.clock.quietHours.join('–'), stateDir: c.stateDir, messaging: adapter.messaging?.instructions ?? '',
|
||||
};
|
||||
return `${common}\n\n${own}`.replace(/\{\{([\w.]+)\}\}/g, (m, k) => vars[k] ?? m);
|
||||
}
|
||||
@@ -308,6 +312,25 @@ async function status() {
|
||||
for (const id of report.recovered) console.log(`nudged ${id} again`);
|
||||
}
|
||||
|
||||
function healthCmd() {
|
||||
const report = fleetHealth(c);
|
||||
if (argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
|
||||
else console.log(formatHealth(report));
|
||||
}
|
||||
|
||||
function wakesCmd() {
|
||||
const report = readWakeLog(c);
|
||||
if (!report.entries.length) {
|
||||
console.log(`no wake attempts in ${report.path}`);
|
||||
return;
|
||||
}
|
||||
console.log(report.path);
|
||||
for (const e of report.entries) {
|
||||
if (e.kind === 'stale') console.log(`${e.t} stale ${e.area} ${e.file} ${e.flag}`);
|
||||
else console.log(`${e.t} ${e.trigger} ${e.area} ${e.window} ${e.file || '-'} ${e.action} enter=${e.enterAttempts}${Array.isArray(e.enterDelays) && e.enterDelays.length ? ` delays=${e.enterDelays.join('+')}` : ''} ${e.outcome} ${e.flag}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorktree(a) {
|
||||
if (fs.existsSync(a.worktree)) return true;
|
||||
const dirty = uncommittedFleetFiles();
|
||||
@@ -618,7 +641,7 @@ function tool(name) {
|
||||
const run = { up, down, status, supervise: () => supervise(argv[1]), worktrees, crontab,
|
||||
prompt: async () => console.log(composePrompt(getArea(c, argv[1]), await adapterFor(getArea(c, argv[1]).harness))),
|
||||
'run-job': () => runJob(argv[1]), 'sched-env': () => schedEnv(argv[1]), lander: () => lander(argv[1] ?? 'status'),
|
||||
commit: commitCmd, 'setup-commit': setupCommitCmd, update: updateCmd, tell: tellCmd, hear: hearCmd,
|
||||
commit: commitCmd, 'setup-commit': setupCommitCmd, update: updateCmd, tell: tellCmd, hear: hearCmd, wakes: wakesCmd, health: healthCmd,
|
||||
doctor: () => tool('doctor'), landq: () => tool('landq'), backlog: () => tool('backlog'),
|
||||
mailbox: () => tool('mailbox') }[cmd];
|
||||
if (!run) die(`unknown command "${cmd}" (fleet help)`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { healthThresholds } from './core/health.mjs';
|
||||
|
||||
export const KIT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
|
||||
@@ -117,6 +118,7 @@ export function loadConfig(argv = process.argv, start = process.cwd()) {
|
||||
c.landing = { batch: 8, intervalSec: 60, protect: [], ...(c.landing || {}) };
|
||||
c.clock = { quietHours: ['22:00', '08:00'], ...(c.clock || {}) };
|
||||
c.doctor = { staleHours: 72, unownedDays: 14, ...(c.doctor || {}) };
|
||||
c.health = healthThresholds(c.health);
|
||||
c.modules = { producer: false, reviewer: false, usageGovernor: false, ...(c.modules || {}) };
|
||||
c.schedules ??= [];
|
||||
c.areas = (c.areas || []).map((a) => resolveArea(c, a));
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
setupCommit, tellDispatch, hearInterpreter, fleetStatus,
|
||||
} from './core/ops.mjs';
|
||||
import { fleetUpdate } from './core/update.mjs';
|
||||
import { readWakeLog } from './core/wake-log.mjs';
|
||||
import { fleetHealth } from './core/health.mjs';
|
||||
|
||||
export const SERVER = { name: 'agent-fleet', version: '0.2.0' };
|
||||
const INSTRUCTIONS = [
|
||||
@@ -18,7 +20,7 @@ const INSTRUCTIONS = [
|
||||
'mailbox_read at the start of a turn. Mail is always delivered; the prompt says when to act on it. Sandboxed agents commit with the commit tool (message plus paths, or all), not git commit. submit on that tool also queues the tip. Then go back to work. At your next natural checkpoint, land_status can report that submission. Waiting is optional. Never infer the lander or the land result from silence. landq_submit queues the committed tip (a dirty worktree warns and still queues HEAD) and starts a drain. Do not push main.',
|
||||
'A scarce machine is a prompt convention: say so in .task and wait if a peer already has it. There is no slot lock.',
|
||||
'Sessions, worktrees, and cron stay on the fleet CLI. This server does not start them.',
|
||||
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. Do not scrape the tmux pane.',
|
||||
'setup_commit commits only the fleet setup files on the main checkout. A sandboxed agent uses it instead of git commit. update refreshes vendored runtime and the registered skill to this kit, leaves an edited prompt in place (the new template is a .kit file beside it), and commits only the paths it changed. It does not change fleet.config.json, product files, mail, or the landing queue. If that commit cannot write the index, it falls back to setup_commit. Mail to an open tmux window wakes it with a short line, not the body. mailbox_read and hear take those messages out of unread. fleet_status reports each window alive or dead, the unread count, and a working or idle guess. An idle area with mail that has been waiting is nudged once by that check. mailbox_read, hear, and tell run that same check. A second look does not nudge again. wake_log says whether the wake line was still in the composer, a waiter typed it after the pane was busy, the line left the composer but the mail is still unread, or the pane was not there. The missing-pane line names the windows that were. Tell the user that case. Do not ask them to open the log. health is one snapshot of CPU, memory, swap, disk, temperature, battery, and each area process tree. Missing sensors are unavailable. The config health block is guidance. Say the verdict in plain words. Do not scrape the tmux pane.',
|
||||
].join(' ');
|
||||
|
||||
const cwdProp = { type: 'string', description: 'Directory to resolve the fleet from. Defaults to this server\'s cwd.' };
|
||||
@@ -39,6 +41,8 @@ export const TOOLS = [
|
||||
{ name: 'tell', description: 'Mail the session manager from the interpreter. Wakes its tmux window with a short line when that window is open. The body stays in the mail. Replies are read with hear.', inputSchema: { type: 'object', properties: { body: { type: 'string' }, subject: { type: 'string' }, cwd: cwdProp }, required: ['body'] } },
|
||||
{ name: 'hear', description: 'Read unread mailbox messages for the interpreter and move them out of unread.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'fleet_status', description: 'Each area: tmux window alive or dead, unread mail count, and a working or idle guess. Idle with unread mail that has been waiting is nudged once.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
{ name: 'wake_log', description: 'Recent wake attempts from the fleet state dir. Each line says the pane state, what was typed or skipped, and a plain-words flag when a wake likely failed. No mail bodies.', inputSchema: { type: 'object', properties: { limit: { type: 'number' }, cwd: cwdProp } } },
|
||||
{ name: 'health', description: 'One snapshot of CPU, memory, swap, disk free, temperature, battery, and per-area CPU and memory. Missing sensors are unavailable. The verdict names which suggested limits were crossed.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
||||
];
|
||||
|
||||
function configFor(args) {
|
||||
@@ -66,6 +70,8 @@ export function dispatchTool(name, args = {}) {
|
||||
case 'tell': return tellDispatch(c, { body: args.body, subject: args.subject ?? '' });
|
||||
case 'hear': return hearInterpreter(c);
|
||||
case 'fleet_status': return fleetStatus(c);
|
||||
case 'wake_log': return readWakeLog(c, { limit: args.limit });
|
||||
case 'health': return fleetHealth(c);
|
||||
default: throw new FleetError(`unknown tool ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user