From af4203ca923aa96b18f26b35a24ed6030dd02ac6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 26 Sep 2026 14:54:18 -0500 Subject: [PATCH] feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512) One cumulative control-board, webui and seat state. The four rows edit the same files (scan.mjs, page.html, README.md, app.js), so they land together, each on its own receipt: - Row 18, Discord connector rows on the board (#1509): R3 approved by Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason accepted the visual test. - Row 22, board attention status (#1503): Filbert approved R1, comment 26248, manifest e40b58ec; restart receipt 26249. - #1511, task attribution (row 6 code phase): R2 approved by Filbert and Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage line (tools-usage.patch 86bcba3c). - #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and Dewey, candidate manifest 47769fad. All seven source files match it. Row 16, internal development bootstrap (#1510): the seven files outside shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*, scripts/test-darkwing-launch.mjs, the bootstrap plan). packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4. The working copy holds Dewey's unreviewed return-flow candidate on top of that, and it stays uncommitted. Also: the four row briefs and Darkwing's evidence records under agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512 re-run against 21e3e908. Serial acceptance command: 397/397, three runs. The failures that only show when tests run concurrently are in #1509 engine tests, and they reproduce on clean HEAD. Suites on the exact staged tree: config 24, task 90, foundation 43, conductor 17, release 14, auth 15, discord 63; package union 397/397 (serial); test-darkwing-launch 5/5. Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md, agents/README.md) follow in Sage's records commit. Co-Authored-By: Claude Opus 5.5 --- agents/darkwing/work/board-attention/r1.json | 17 ++ .../board-attention/restart-verification.json | 14 ++ .../work/board-attention/restart.json | 46 +++++ .../darkwing/work/discord-board/acceptance.md | 28 +++ .../backend-live-verification.json | 23 +++ .../work/discord-board/backend-restart.jsonl | 2 + .../work/discord-board/r1-backend-review.json | 18 ++ .../discord-board/r2-envelope-review.json | 7 + .../work/discord-board/r3-backend-review.json | 18 ++ .../discord-board/r3-live-observation.json | 20 ++ .../work/discord-board/reply-baseline.json | 9 + .../live-smoke.json | 25 +++ .../r1-review.json | 19 ++ .../work/relaunch-activity/baseline.json | 9 + .../dependency-comparison.json | 6 + .../relaunch-activity/engine-diagnosis.md | 58 ++++++ .../work/relaunch-activity/issue.json | 1 + .../relaunch-activity/r1-dependencies.sha256 | 89 +++++++++ .../r1-provenance-correction.json | 17 ++ .../work/relaunch-activity/r1-review.md | 43 +++++ .../relaunch-activity/rerun-2026-09-26.md | 95 ++++++++++ .../backend-live-verification.json | 18 ++ .../task-attribution/backend-restart.jsonl | 2 + .../work/task-attribution/baseline.json | 7 + .../task-attribution/live-test-envelope.md | 34 ++++ .../work/task-attribution/live-test.jsonl | 3 + .../work/task-attribution/r1-manifest.sha256 | 15 ++ .../work/task-attribution/r1-review.md | 46 +++++ .../work/task-attribution/r2-manifest.sha256 | 15 ++ .../work/task-attribution/r2-review.md | 41 +++++ .../reader-compatibility.json | 8 + .../work/task-attribution/tools-usage.patch | 5 + .../darkwing/work/tree-manifest-2026-09-26.md | 161 ++++++++++++++++ agents/researcher/CONTEXT.md | 34 ++++ agents/researcher/README.md | 33 ++++ agents/researcher/SOUL.md | 9 +- agents/researcher/launch.sh | 8 + agents/researcher/validate-sessions.mjs | 21 +++ docs/TOOLS.md | 2 +- .../2026-09-13_board-attention-status.md | 42 +++++ ...26-09-13_internal-development-bootstrap.md | 60 ++++++ docs/plans/2026-09-14_discord-board-row.md | 65 +++++++ docs/plans/2026-09-14_task-attribution.md | 45 +++++ docs/plans/2026-09-15_relaunch-activity.md | 35 ++++ packages/control-board/README.md | 92 +++++++++- packages/control-board/src/cli.mjs | 10 +- packages/control-board/src/discord.mjs | 73 ++++++++ packages/control-board/src/page.html | 28 ++- packages/control-board/src/scan.mjs | 65 +++++-- packages/control-board/src/serve.mjs | 5 +- .../tests/attention-flow.test.mjs | 55 ++++++ .../control-board/tests/attention.test.mjs | 29 +++ packages/control-board/tests/discord.test.mjs | 145 +++++++++++++++ .../control-board/tests/relaunch.test.mjs | 85 +++++++++ packages/control-board/tests/scan.test.mjs | 106 +++++++++-- packages/control-board/tests/serve.test.mjs | 69 ++++++- packages/seat/README.md | 30 ++- packages/seat/src/cli.mjs | 21 ++- packages/seat/src/seat.mjs | 43 ++++- packages/seat/tests/seat.test.mjs | 173 +++++++++++++++++- packages/webui/src/public/app.js | 16 +- packages/webui/tests/browser-edge.test.mjs | 29 ++- packages/webui/tests/browser.test.mjs | 13 ++ packages/webui/tests/discord.test.mjs | 62 +++++++ packages/webui/tests/fixture.mjs | 10 +- packages/webui/tests/relaunch.test.mjs | 61 ++++++ scripts/test-darkwing-launch.mjs | 2 +- 67 files changed, 2417 insertions(+), 78 deletions(-) create mode 100644 agents/darkwing/work/board-attention/r1.json create mode 100644 agents/darkwing/work/board-attention/restart-verification.json create mode 100644 agents/darkwing/work/board-attention/restart.json create mode 100644 agents/darkwing/work/discord-board/acceptance.md create mode 100644 agents/darkwing/work/discord-board/backend-live-verification.json create mode 100644 agents/darkwing/work/discord-board/backend-restart.jsonl create mode 100644 agents/darkwing/work/discord-board/r1-backend-review.json create mode 100644 agents/darkwing/work/discord-board/r2-envelope-review.json create mode 100644 agents/darkwing/work/discord-board/r3-backend-review.json create mode 100644 agents/darkwing/work/discord-board/r3-live-observation.json create mode 100644 agents/darkwing/work/discord-board/reply-baseline.json create mode 100644 agents/darkwing/work/internal-development-bootstrap/live-smoke.json create mode 100644 agents/darkwing/work/internal-development-bootstrap/r1-review.json create mode 100644 agents/darkwing/work/relaunch-activity/baseline.json create mode 100644 agents/darkwing/work/relaunch-activity/dependency-comparison.json create mode 100644 agents/darkwing/work/relaunch-activity/engine-diagnosis.md create mode 100644 agents/darkwing/work/relaunch-activity/issue.json create mode 100644 agents/darkwing/work/relaunch-activity/r1-dependencies.sha256 create mode 100644 agents/darkwing/work/relaunch-activity/r1-provenance-correction.json create mode 100644 agents/darkwing/work/relaunch-activity/r1-review.md create mode 100644 agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md create mode 100644 agents/darkwing/work/task-attribution/backend-live-verification.json create mode 100644 agents/darkwing/work/task-attribution/backend-restart.jsonl create mode 100644 agents/darkwing/work/task-attribution/baseline.json create mode 100644 agents/darkwing/work/task-attribution/live-test-envelope.md create mode 100644 agents/darkwing/work/task-attribution/live-test.jsonl create mode 100644 agents/darkwing/work/task-attribution/r1-manifest.sha256 create mode 100644 agents/darkwing/work/task-attribution/r1-review.md create mode 100644 agents/darkwing/work/task-attribution/r2-manifest.sha256 create mode 100644 agents/darkwing/work/task-attribution/r2-review.md create mode 100644 agents/darkwing/work/task-attribution/reader-compatibility.json create mode 100644 agents/darkwing/work/task-attribution/tools-usage.patch create mode 100644 agents/darkwing/work/tree-manifest-2026-09-26.md create mode 100644 agents/researcher/CONTEXT.md create mode 100644 agents/researcher/README.md create mode 100755 agents/researcher/launch.sh create mode 100644 agents/researcher/validate-sessions.mjs create mode 100644 docs/plans/2026-09-13_board-attention-status.md create mode 100644 docs/plans/2026-09-13_internal-development-bootstrap.md create mode 100644 docs/plans/2026-09-14_discord-board-row.md create mode 100644 docs/plans/2026-09-14_task-attribution.md create mode 100644 docs/plans/2026-09-15_relaunch-activity.md create mode 100644 packages/control-board/src/discord.mjs create mode 100644 packages/control-board/tests/attention-flow.test.mjs create mode 100644 packages/control-board/tests/attention.test.mjs create mode 100644 packages/control-board/tests/discord.test.mjs create mode 100644 packages/control-board/tests/relaunch.test.mjs create mode 100644 packages/webui/tests/discord.test.mjs create mode 100644 packages/webui/tests/relaunch.test.mjs diff --git a/agents/darkwing/work/board-attention/r1.json b/agents/darkwing/work/board-attention/r1.json new file mode 100644 index 00000000..887256d3 --- /dev/null +++ b/agents/darkwing/work/board-attention/r1.json @@ -0,0 +1,17 @@ +{ + "issue": 1503, + "candidate": "/tmp/board-attention-r1-q_924ksh", + "files": [ + "AGENTS.md", + "packages/control-board/src/scan.mjs", + "packages/control-board/README.md", + "packages/control-board/tests/scan.test.mjs", + "packages/control-board/tests/serve.test.mjs", + "packages/control-board/tests/attention.test.mjs", + "packages/control-board/tests/attention-flow.test.mjs", + "packages/webui/tests/fixture.mjs", + "docs/plans/2026-09-13_board-attention-status.md" + ], + "manifestSha256": "e40b58ecb6844d407ba776dcde8f19ad21c0b78076ca1f9b3d96b0bb1c852405", + "testsPassed": 144 +} diff --git a/agents/darkwing/work/board-attention/restart-verification.json b/agents/darkwing/work/board-attention/restart-verification.json new file mode 100644 index 00000000..42aedf4a --- /dev/null +++ b/agents/darkwing/work/board-attention/restart-verification.json @@ -0,0 +1,14 @@ +{ + "at": "2026-09-14T00:19:32.409806+00:00", + "backendPid": 3204655, + "health": "ok", + "researcher": { + "agent": "researcher", + "project": "mosaic-stack", + "alive": true, + "state": "idle", + "waitingOnYou": false, + "lastActivity": "2026-09-13T21:19:57.102Z" + }, + "fiveAgentProcessesUnchanged": true +} diff --git a/agents/darkwing/work/board-attention/restart.json b/agents/darkwing/work/board-attention/restart.json new file mode 100644 index 00000000..b95a5b6d --- /dev/null +++ b/agents/darkwing/work/board-attention/restart.json @@ -0,0 +1,46 @@ +{ + "at": "2026-09-14T00:19:11.579013+00:00", + "ownerAuthorized": true, + "oldPid": 1265952, + "newPid": 3204655, + "command": [ + "/usr/bin/node", + "packages/control-board/src/cli.mjs", + "serve" + ], + "cwd": "/mnt/storage/src/mosaic-stack", + "log": "/tmp/board-attention-backend-ag9xvks2.log", + "agentProcessesBefore": { + "default/darkwing": [ + [ + "2733924", + "12863634" + ] + ], + "default/dewey": [ + [ + "934346", + "466065" + ] + ], + "default/filbert": [ + [ + "72183", + "100870" + ] + ], + "default/researcher": [ + [ + "173699", + "66404285" + ] + ], + "mosaic-fleet/rocko": [ + [ + "90599", + "128275" + ] + ] + }, + "gracefulExitObserved": true +} diff --git a/agents/darkwing/work/discord-board/acceptance.md b/agents/darkwing/work/discord-board/acceptance.md new file mode 100644 index 00000000..a1e28a00 --- /dev/null +++ b/agents/darkwing/work/discord-board/acceptance.md @@ -0,0 +1,28 @@ +# Independent acceptance checklist, row 18 + +Darkwing reviews Filbert's implementation without editing its source candidate. +Dewey reviews visible connector presentation. No live connector manipulation. + +- Discovery accepts only safe matching binding name/seat from private regular + files, never dereferences a token path and never serializes private fields. +- Path traversal, symlinked binding/runtime/session paths and malformed records + cannot cause arbitrary reads or an actionable/live row. +- No owner, malformed owner, dead PID, missing identity, reused PID and boot + mismatch are non-live. A positively matching live process is live. +- STOP presence is visible as braked independently of process liveness. Its + contents are not read or exposed; no STOP or lock is created or changed. +- Ordinary completed messages remain idle. No false human attention regression. +- Connector rows cannot borrow a native agent's registration for replies. + Exercise replyToRow and HTTP using a fake executable hook; every connector + attempt must be refused before that hook runs, including with forged tmux + registration. Normal-agent reply tests must still pass. +- Both existing board and WebUI distinguish the connector and brake state and + omit reply controls. Preserve escaping, including hostile binding fixtures. +- Discovery errors disclose no private JSON fields or raw contents. One bad + binding must not silently manufacture a healthy row. +- Candidate pins match before and after tests. Existing dirty attention changes + remain intact; no unrelated source integration or live operation is inferred. + +After source approval, measure the real row read-only. Offline/braked behavior +uses isolated fixtures unless the operator separately approves a live-service +transition. Board replacement is its own protected gate. diff --git a/agents/darkwing/work/discord-board/backend-live-verification.json b/agents/darkwing/work/discord-board/backend-live-verification.json new file mode 100644 index 00000000..bc6ebb3e --- /dev/null +++ b/agents/darkwing/work/discord-board/backend-live-verification.json @@ -0,0 +1,23 @@ +{ + "at": "2026-09-14T13:51:10.530662+00:00", + "backendPid": 3769124, + "health": "ok", + "row": { + "agent": "sage (discord: shared-signals)", + "project": "fleet", + "state": "idle", + "alive": true, + "connector": { + "binding": "shared-signals", + "braked": false, + "ownerState": "live", + "alive": true + }, + "task": "Discord connector", + "taskSource": "connector" + }, + "replyStatus": 409, + "replyError": "board replies are disabled for Discord connectors", + "fiveAgentPaneIdentitiesUnchanged": true, + "connectorServiceIdentityUnchanged": true +} diff --git a/agents/darkwing/work/discord-board/backend-restart.jsonl b/agents/darkwing/work/discord-board/backend-restart.jsonl new file mode 100644 index 00000000..27ab1b57 --- /dev/null +++ b/agents/darkwing/work/discord-board/backend-restart.jsonl @@ -0,0 +1,2 @@ +{"at": "2026-09-14T13:50:24.078636+00:00", "event": "owner-authorized-restart-intent", "oldPid": 3204655, "agents": {"default/darkwing": [["2733924", "12863634"]], "default/dewey": [["934346", "466065"]], "default/filbert": [["72183", "100870"]], "default/researcher": [["173699", "66404285"]], "mosaic-fleet/rocko": [["90599", "128275"]]}, "connectorService": [3022843, "67887873"], "manifest": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de"} +{"at": "2026-09-14T13:50:24.503231+00:00", "event": "replacement-started", "oldExitedGracefully": true, "newPid": 3769124, "log": "/tmp/discord-board-backend-ovk_cahk.log"} diff --git a/agents/darkwing/work/discord-board/r1-backend-review.json b/agents/darkwing/work/discord-board/r1-backend-review.json new file mode 100644 index 00000000..2d807e14 --- /dev/null +++ b/agents/darkwing/work/discord-board/r1-backend-review.json @@ -0,0 +1,18 @@ +{ + "at": "2026-09-14T01:10:19.375709+00:00", + "candidate": "/tmp/discord-board-r1-KbMrGQWF", + "manifestSha256": "5c92acc90d202727d790f3fb8d74387db1c9e5c3e43d4f4b56b40e5ae503a56a", + "verdict": "CHANGES REQUIRED", + "independentSerializedTests": 320, + "finding": { + "id": "R1-B1", + "severity": "P2", + "file": "packages/control-board/src/discord.mjs", + "issue": "STOP metadata access errors collapse to absence, falsely projecting not braked", + "reproduction": "Synthetic journal directory contains STOP, chmod directory to 000 as uid 1000, inspectDiscord returns braked:false, ownerState:invalid, alive:false. Restore permissions and remove fixture.", + "expected": "braked:null/unknown when STOP existence cannot be established; false only for verified absence", + "required": "Distinguish missing metadata from access errors and add non-root unreadable-directory regression." + }, + "ux": "Dewey APPROVE on exact R1; three independent serialized browser tests passed, source/automation limitations retained", + "parallelQualification": "Two author concurrent frozen timeouts remain unresolved and are not green; serialized independent run passed." +} diff --git a/agents/darkwing/work/discord-board/r2-envelope-review.json b/agents/darkwing/work/discord-board/r2-envelope-review.json new file mode 100644 index 00000000..ff477afe --- /dev/null +++ b/agents/darkwing/work/discord-board/r2-envelope-review.json @@ -0,0 +1,7 @@ +{ + "candidate": "R2", + "syntheticOnly": true, + "taskContainsEnvelopeAuthorId": true, + "taskContainsEnvelopeMessageId": true, + "taskSource": "first-user-message" +} diff --git a/agents/darkwing/work/discord-board/r3-backend-review.json b/agents/darkwing/work/discord-board/r3-backend-review.json new file mode 100644 index 00000000..0d1ab960 --- /dev/null +++ b/agents/darkwing/work/discord-board/r3-backend-review.json @@ -0,0 +1,18 @@ +{ + "at": "2026-09-14T01:26:42.688410+00:00", + "candidate": "/tmp/discord-board-r3-U9vVrlQu", + "manifestSha256": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de", + "reviewer": "Darkwing", + "backendVerdict": "APPROVE AS SOURCE", + "verified": "Nine working/frozen pins, exact three-file R2-to-R3 delta, inherited attention pins and full serialized six-package suite 322/322", + "findingsClosed": [ + "R1-B1: inaccessible STOP is unknown, non-root regression passes", + "R2-B2: canonical routing envelope no longer becomes connector Task; ordinary fallback retained" + ], + "limitations": [ + "No generalized transcript redaction", + "R1 concurrent combined frozen timeouts unresolved/not green", + "No live observation, backend restart, connector change or publication in this review" + ], + "uxGate": "Await exact R3 confirmation from Dewey via agent-send" +} diff --git a/agents/darkwing/work/discord-board/r3-live-observation.json b/agents/darkwing/work/discord-board/r3-live-observation.json new file mode 100644 index 00000000..34839cad --- /dev/null +++ b/agents/darkwing/work/discord-board/r3-live-observation.json @@ -0,0 +1,20 @@ +{ + "at": "2026-09-14T01:28:17.695Z", + "sourceApproval": 26257, + "readOnly": true, + "agent": "sage (discord: shared-signals)", + "project": "fleet", + "state": "idle", + "alive": true, + "connector": { + "binding": "shared-signals", + "braked": false, + "ownerState": "live", + "alive": true + }, + "task": "Discord connector", + "taskSource": "connector", + "registrationAbsent": true, + "ownerMatchesService": true, + "discoveryErrorCount": 0 +} diff --git a/agents/darkwing/work/discord-board/reply-baseline.json b/agents/darkwing/work/discord-board/reply-baseline.json new file mode 100644 index 00000000..b098f909 --- /dev/null +++ b/agents/darkwing/work/discord-board/reply-baseline.json @@ -0,0 +1,9 @@ +{ + "at": "2026-09-14T00:50:35.339Z", + "sourceSha256": "dfbb7ab9374c0ac9fafa0503f495abd938f499f5d6227233de03a60ea3022927", + "fixture": "connector row with forged native registration", + "status": 200, + "fakeTransportCalls": 1, + "realTransportCalls": 0, + "gatePassed": false +} diff --git a/agents/darkwing/work/internal-development-bootstrap/live-smoke.json b/agents/darkwing/work/internal-development-bootstrap/live-smoke.json new file mode 100644 index 00000000..ba86ae86 --- /dev/null +++ b/agents/darkwing/work/internal-development-bootstrap/live-smoke.json @@ -0,0 +1,25 @@ +{ + "observedAt": "2026-09-13T19:56:18.961314+00:00", + "issue": 1510, + "ownerAuthorizedLiveSmoke": true, + "researcher": [ + { + "session": ".pi/state/researcher/sessions/2026-09-13T19-52-28-745Z_01a09c54-0b48-7154-addd-8fdce875aa4a.jsonl", + "entryId": "64b15fd5", + "timestamp": "2026-09-13T19:52:43.079Z", + "response": "RESEARCHER_NATIVE_SMOKE_OK", + "entrySha256": "d9cbaa5ea640d2b858a86b2fa24d3240b351d9383ffaee9721dd86fcd080c329" + } + ], + "rocko": { + "newLaunch": "refused by existing native launch lock", + "existingPid": 3707667, + "cwd": "/mnt/storage/src/mosaic-stack", + "nativeContextVerified": true, + "sonnetFlagVerified": true, + "socket": "mosaic-fleet", + "newModelResponseTested": false + }, + "existingProcessesRestarted": false, + "homeLaunchersModified": false +} diff --git a/agents/darkwing/work/internal-development-bootstrap/r1-review.json b/agents/darkwing/work/internal-development-bootstrap/r1-review.json new file mode 100644 index 00000000..6fde11b5 --- /dev/null +++ b/agents/darkwing/work/internal-development-bootstrap/r1-review.json @@ -0,0 +1,19 @@ +{ + "issue": 1510, + "candidate": "/tmp/internal-team-r1-i9t21yzr", + "manifestSha256": "23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d", + "files": [ + "AGENTS.md", + "agents/README.md", + "agents/researcher/SOUL.md", + "agents/researcher/CONTEXT.md", + "agents/researcher/README.md", + "agents/researcher/launch.sh", + "agents/researcher/validate-sessions.mjs", + "scripts/test-darkwing-launch.mjs", + "docs/plans/2026-09-13_internal-development-bootstrap.md" + ], + "tests": "node --test scripts/test-darkwing-launch.mjs scripts/test-rocko-launch.mjs", + "passed": 6, + "state": "ready for independent review" +} diff --git a/agents/darkwing/work/relaunch-activity/baseline.json b/agents/darkwing/work/relaunch-activity/baseline.json new file mode 100644 index 00000000..7f7e61e6 --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/baseline.json @@ -0,0 +1,9 @@ +{ + "at": "2026-09-15T00:46:26.639Z", + "issue": 1512, + "startedAt": "2026-09-11T00:00:00.000Z", + "lastActivity": "2026-09-10T00:00:00.000Z", + "lastAssistantText": "OLD_FIXTURE_REPLY", + "oldReplyStillPresented": true, + "syntheticOnly": true +} diff --git a/agents/darkwing/work/relaunch-activity/dependency-comparison.json b/agents/darkwing/work/relaunch-activity/dependency-comparison.json new file mode 100644 index 00000000..429357e3 --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/dependency-comparison.json @@ -0,0 +1,6 @@ +{ + "discordDependencyCommit": "1ac812d3d5f3939221e7578502a10240ac82a6af", + "verifiedTrackedDependencyFiles": 31, + "sevenWorkingAndFrozenPinsUnchanged": true, + "scope": "byte comparison, not proof of snapshot construction history" +} diff --git a/agents/darkwing/work/relaunch-activity/engine-diagnosis.md b/agents/darkwing/work/relaunch-activity/engine-diagnosis.md new file mode 100644 index 00000000..3d4a5e7a --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/engine-diagnosis.md @@ -0,0 +1,58 @@ +# #1512 inherited engine diagnosis + +Rocko returned a read-only investigation. No source correction, live connector +operation or authority transfer follows from it. Filbert's seven source pins +remain the #1512 candidate; they do not change Discord engine code. + +## A. Test synchronization and cleanup + +Rocko reports the tool-turn test asserts engine.busy immediately after prompt +resolution at agent_end, while busy clears on a subsequent agent_settled event. +Separate pipe reads allow the assertion to run between events. Another basic +prompt test uses the same assumption. Success-only engine.stop cleanup leaves +the fake child and pipes open after an assertion fails, hiding the final spec +report and making the test file hang. + +Darkwing independently ran Rocko's scratch relay with DELAY=50 and finally cleanup. +It failed 'busy after plain resolved', true versus false, and exited in 148ms. +Log: /tmp/darkwing-1512-race-confirmation.txt. This confirms the race mechanism, +not the exact historical assertion from the earlier truncated spec log. + +Rocko's scratch directory: +/tmp/claude-1000/-mnt-storage-src-mosaic-stack/0422f20f-4d8c-43fe-b429-a2730cc1ab9d/scratchpad/1512/ +Includes proxy-pi.mjs, race-repro.test.mjs, hang-repro.test.mjs, +hang-noforce.txt, hang-force.txt and stress/base-prev run logs. +He reports natural busy failures at lines56/75 under contention and deterministic +hang behavior without finally cleanup. Darkwing did not repeat load stress. + +## B. Timeout test synchronization + +Rocko reports a 100ms timeout test reads fake-pi's command log before the child +has created it or recorded abort under contention. That assertion can also skip +success-only cleanup. He observed this in both prior and row21 code. Not the +specific failed test named in Darkwing's first run. + +## C. Separate engine behavior concern + +Rocko reports a next-prompt refusal after a client timeout when a done pending +entry remains, busy is false and agent_start has not yet been consumed. prompt() +then omits followUp because it considers only pending entries not marked done. +The still-streaming fake engine refuses that prompt. This is a potential real +engine defect, not merely the busy assertion race. No live incident is established. + +The row21 owner retains this code. Darkwing notified orch-01 on its verified named +socket and requested ownership/disposition, not a code takeover. A focused owner +fix needs regression coverage and review. Suggested direction from Rocko: account +for unsettled pending runs when choosing followUp; do not blindly change logic +from this note alone. Test cleanup and event synchronization also need correction. + +## Provenance and gates + +Rocko reports frozen Discord files match row21 commit1ac812d3, not the stated +archive basec4fc8e7d in HANDOFF. Darkwing requested Filbert's construction evidence +and append-only provenance correction outside frozen R1. Do not rewrite the old +handoff or infer its base from a mutable HEAD. + +The later 351/351 bounded run remains valid narrow evidence; the earlier failure +is not erased. Integration stays held for owner disposition and provenance +reconciliation. No live action, publication, operator acceptance or GateF claim. diff --git a/agents/darkwing/work/relaunch-activity/issue.json b/agents/darkwing/work/relaunch-activity/issue.json new file mode 100644 index 00000000..7f018586 --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/issue.json @@ -0,0 +1 @@ +{"number": 1512, "creator": "darkwing", "charter": "docs/plans/2026-09-15_relaunch-activity.md"} diff --git a/agents/darkwing/work/relaunch-activity/r1-dependencies.sha256 b/agents/darkwing/work/relaunch-activity/r1-dependencies.sha256 new file mode 100644 index 00000000..4f640e09 --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/r1-dependencies.sha256 @@ -0,0 +1,89 @@ +1e1aef2c74434e388d61b03407cd323336d20b7ef2106893d16a39638ee12a64 packages/control-board/package.json +8966f1f98ea81b4525345fc4889db5e0e5263ca41b5132ac8dd9bd99a2c88522 packages/control-board/src/discord.mjs +d6ac9b7b9661e6c85e912612b847c14ed38b7596b7db0a86a692da202f2a9f9c packages/control-board/src/serve.mjs +3f8822e97b2ba26b7035f86cf9bac421039957faab7f4543a1ed22c4568db23d packages/control-board/tests/attention-flow.test.mjs +5429fedc72af5cd9b7ddfa3b70e31aa7ecb4c511c51e0b640a16f9773912496a packages/control-board/tests/attention.test.mjs +64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs +06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs +41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs +ce0d74970496f197f507741261b8bf0b8e9c89909a44627eb11221ab407a6a8b packages/discord/README.md +587e01d440096c04ca8f4ae5ea096b4eab846989824f3effde12bbd66c71c6f9 packages/discord/extension/readonly-tools.mjs +d6a144b3c396c02e069a76d0d65bcd842a84f9de618b0172f1e1f7fed42757f9 packages/discord/fixtures/binding.example.json +4bfa50b150d49086e6438625c78261b58e6afb90601a9fb7ea464cc03faa29fb packages/discord/fixtures/claim-worker.mjs +012ce50053e0a38bcb2aebc4011c24ed41a93d4d7b79790f7ba0fc0049f0bc16 packages/discord/fixtures/legacy-owner-worker.mjs +a52c6bfc9bd26a3b021d3a96bddb0f5c45845d752ca4579ef04ef60667f110f3 packages/discord/package.json +573b015bc7a929903e33657f3cf79f0c6da1e927b3f9188f842ee867ec097d3f packages/discord/src/authorize.mjs +73e96f8421b88e3eb71666fb98673f10f659d0cd3535c5ac2897d3e3d431bab3 packages/discord/src/binding.mjs +a12c5a7b41060253ee12a3019f5ec930597f7866df247eb949bdf149223e5e93 packages/discord/src/cli.mjs +ba2132fe37d8ba4c04ea32e88aef7faae27fda4d2121426ab6c373aabe8b9b21 packages/discord/src/connector.mjs +18c583cb72c33bc7e41b9904dade98777e3e3fe8ff0820a56fc85404e8505904 packages/discord/src/context.mjs +67a5aca259731763caac0f199be8cae95c2606d740083eca2dff489b59353bf3 packages/discord/src/engine-pi.mjs +917b0e6c4f100e93f056b925e38d97eacdd8b2596e39fbd3f88c0b62b973d8ed packages/discord/src/errors.mjs +2d7f7fcbe62aac7b724a66714cd24d5634bd63f7660ef5979b8d4fccb0689b08 packages/discord/src/gateway.mjs +db33880f88dc2c7708015efecd0c6db604bc40387ea0dd6259d91f621a5b022c packages/discord/src/journal.mjs +1b83781c46cc8d9437b957e8757ea64a4da15a08d80706745770a06e411e72ff packages/discord/src/rest.mjs +063bbd5b47cd9f0d8a34adde09a5c63bd52d2d88be02a381662406ab07fcbe89 packages/discord/src/tools.mjs +a4ce74917ad68c82bd7ea07d6039813a94cec724c74f18e1e65a8f762d2cb7e1 packages/discord/systemd/mosaic-discord@.service.in +1c1629800b1f1a417bff87e51f716cdc75a6725183a3f518096d21704de39804 packages/discord/tests/authorize.test.mjs +bcead9d2a44da3a12085dab2276f0afea5fd850061ac9dd9c4b64952d07712ee packages/discord/tests/binding.test.mjs +fc6eaa88db5d56d563ac2a83ace7db70c40cb328664c9782af8aaacf4ca4e978 packages/discord/tests/connector.test.mjs +b1cbc424a44d99d5ef6782fa4e17866030953ffa9a6e9ef97bac99d6b20dd576 packages/discord/tests/context.test.mjs +cb30bb0571756c66ff8177189f4722411245bf2f9b49b5a5d219f5009e0758f5 packages/discord/tests/engine.test.mjs +0930b8d13c10febd5ac98e69795c66a5313162c4e20d0e1f446ffdfc0c73bf64 packages/discord/tests/fake-pi.mjs +2fb4d80d228591d8b537a73995403c77c4fcc35fcfab3d8d8ad2705638825c71 packages/discord/tests/gateway.test.mjs +3da0370367aa6d7dea1e0e5edcc62350ad88399577f895b2bd29965e64360e2a packages/discord/tests/helpers.mjs +c70fc1da93e4bead261b2935098918804dfafd29efbf2e4a0256fadfc13c62b4 packages/discord/tests/journal.test.mjs +6ce9e42a35145df726449b01e2b89d9a468ee6bccc4cc754175e0e67f26f43af packages/discord/tests/recover.test.mjs +2c3c9b3a38f6aadb7c490f464573df3ad0dc207457838bdb727f4fbf29b69b75 packages/discord/tests/rest.test.mjs +a1c8203fecc706ea97896eff91a07145446440a7e0ca98b36ed2b038b30d5473 packages/discord/tests/tools.test.mjs +601db1de6d4838785f334dd93dd191d444276f306e016b3a4137659eaae76561 packages/ledger/README.md +e33fc5be5cdeb69f6f443a44c5b02a7bc346ac74dd69f984538e424a8d1b5de9 packages/ledger/src/cli.mjs +fbd95e45c5753cb44e39dfead0c6e0d2a5f07859dd9411f1dd659dde98af2ffc packages/ledger/src/ledger.mjs +c836ecd64068cccf7f515ab699c6a218e5ea1618c39e7e88be21bfa74d96414e packages/ledger/tests/gitea-helper.test.mjs +8e838a5333cbe6aafda5d72b810eb2840c4439973841d9c2bf5b55bc660455a3 packages/ledger/tests/ledger.test.mjs +bfaa5eed44c9eba9c195c38008db785d9c78e58f7544be7e46fd32155708f256 packages/mosaic/README.md +12838980706273f2a3daf84a1a78d55e06018c1f78587cc9db4527a8aa8b074f packages/mosaic/package.json +dd07bea32281e66d0f7d8485595eb403305fac1ca5943daa67a73bc0666caecc packages/mosaic/src/cli/main.mjs +ed9920d465f74387566443544131226050dec8f13efefaad2d48157c8808f162 packages/mosaic/src/execution.mjs +fa5c5fe977916418e7d2c4f6ceeedb6f3bcb6ae854f680bec07f7930b937b1f4 packages/mosaic/src/fixture-store.mjs +ac64c9c32ba928cc557107a47e127350efd724a7d8cdf1f399630964fe621879 packages/mosaic/src/index.mjs +7a3e66fc20e3e51d838c66e2cb0f3c2b73047ffabccb93532bd325a8a65e68c2 packages/mosaic/src/materialize-fixture.mjs +e4d2e55e077f38c5523d0211e6ec90e301804d8f6896674de2bbeaf0b049be58 packages/mosaic/src/records.mjs +28f87b70d0d42d4f4f25a7d72f138e87dcfcd79d542b43da7fb208444f4deeb7 packages/mosaic/src/refresh-fixture.mjs +d33985227f536117bcfad1d7f5ecd777adf02c073c8a9bff634c4b32f2b85e48 packages/mosaic/src/registry.mjs +545c126314e14fb772dbe082adcec554e84bbfdef8f93e231ae825ed5b8ec845 packages/mosaic/tests/fixtures/valid/auth/accounts/openai-codex/homelab-openai/account.json +3bcf68dd33fd1e5b219ffaf3a15880718cb3751ac07f132d2466c408e2fda6f3 packages/mosaic/tests/fixtures/valid/auth/providers/ollama-remote.json +d214e39539b5de9d6f2350b5d681cfa340ff2d255c215d25499aeb10711fcd7f packages/mosaic/tests/fixtures/valid/auth/providers/openai-codex.json +e986f8fd65a5a501510dc6e706d772a5c0ddc336864ff6c41405f3901749ffe1 packages/mosaic/tests/fixtures/valid/auth/settings/research-default.json +ff1b45df2503ccd796c220364ea217c5eb1ab1eaf22ffa8094ff0851dfb20ed3 packages/mosaic/tests/fixtures/valid/harnesses/pi.json +44bcb47057dbaaff69992c8e7355b8195f3db6f8ccb7bd05bb2ea913e3940ea3 packages/mosaic/tests/materialize.test.mjs +7747bace9da600ef93776b3e473efdd3f1f57420aaa3fff207a1fd0c6c5b5e0f packages/mosaic/tests/refresh.test.mjs +1c2f018de202003ddf69f333fc1c066d265b312a431007742aebd7ccca5a93a7 packages/mosaic/tests/registry.test.mjs +1891119852df1930505bbec6e0543b95f82d4ff3834956fe433d9f58c3204645 packages/mosaic/tests/safety.test.mjs +4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md +26ee149f870ff56ad6d1c3d7c3b4b2301879c59f02d5840078c07e7964cc2175 packages/seat/package.json +a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs +eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs +c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs +6eca43194632017ffed73d62ba76c74435c4b661f4ec3fc76d8b0bae8bcf9089 packages/webui/README.md +ee81d2b902a0b250f32d997d27085b9c76a7c98acdd0a0b55c65cf9342047b4e packages/webui/package.json +20f8091e5fb3fcaba583b601ce258999d44afbe569e93c979938ace759b4eef4 packages/webui/src/cli.mjs +a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-400.woff2 +a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-500.woff2 +a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-600.woff2 +a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-700.woff2 +e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c packages/webui/src/public/assets/fonts/manrope-OFL.txt +0d9aaaaa963b675d94d8fbee0cf47a8ecf03ea412f8b14aba96416375e5a2bb5 packages/webui/src/public/assets/fonts/manrope-sources.txt +d568df6dca14ae336db3b627639c8181c088649a21ffe992f9bfacc20b5b6238 packages/webui/src/public/brand.js +1ce47447339a274dd51c0168838a5e58e0addaf2200e04625bc1da5bc9279097 packages/webui/src/public/console.css +db055d5b8a3710f4164193550ac25b7c5d223dd890ae3e9d73daaada38849557 packages/webui/src/public/index.html +7add449911677c662a05ad5d9c2462f919e759574fe4b85bd13a705cc54f6856 packages/webui/src/public/live.css +b3e6e240fa93555e30321f897405839856b3438ef44b1cea73d2f5fbd899077e packages/webui/src/public/shared/app.css +c0c1a360ce44a7439e67eea36b9237c59621aa3fd78571a823fd23dc15a950f6 packages/webui/src/serve.mjs +b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs +3ff14de07e87cc624433db6425b9d05e40d98f61e40f2bbf1fbee7431f9d73c1 packages/webui/tests/browser.mjs +01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs +24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs +0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs +15e77422890bc50cdb700484ca7e16ac32476aa8ec61d5575a6e0a920694bc4c packages/webui/tests/serve.test.mjs +2c822c787f1e6e5779aa74a5c054845de31f5f990e5a4b97a1398b9aaa856a7e scripts/test-discord.sh diff --git a/agents/darkwing/work/relaunch-activity/r1-provenance-correction.json b/agents/darkwing/work/relaunch-activity/r1-provenance-correction.json new file mode 100644 index 00000000..01340bce --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/r1-provenance-correction.json @@ -0,0 +1,17 @@ +{ + "recordedAt": "2026-09-15T01:13:19.864188+00:00", + "author": "Filbert", + "issue": 1512, + "kind": "append-only provenance correction; supersedes only HANDOFF archive-base statement", + "frozenCandidate": "/tmp/relaunch-activity-r1-8ckPxKjN", + "candidateManifestSHA256": "47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d", + "incorrectEarlyCheckpoint": "c4fc8e7d7f76b2b8cda6a6a0f6fc760d37235c98", + "verifiedArchiveContentBase": "1ac812d3d5f3939221e7578502a10240ac82a6af", + "construction": "git archive HEAD | tar -x -C \"$SNAP\", then working packages/control-board, packages/webui and packages/seat overlays, charter and red log copies. HEAD was not pinned in the archive command. The earlier checkpoint was incorrectly reused in HANDOFF.", + "verification": "All 4509 archive files outside the three overlay package directories match 1ac812d3 byte-for-byte, with no differences or missing files. c4fc8e7d comparison had 21 differing files and predates additional new files. All tracked Discord files and scripts/test-discord.sh match 1ac812d3. Seven candidate hashes still match original manifest.", + "dependencyManifest": "DEPENDENCIES.sha256", + "dependencyManifestSHA256": "deae75b488b2f9172e7168bba83e442a251c294464ee6200527c23ef4599f968", + "dependencyFiles": 89, + "dependencyScope": "All files in six tested packages except seven authored candidate paths, plus scripts/test-discord.sh, pinned from frozen R1 rather than current checkout.", + "limits": "R1 and all prior evidence unchanged. Prior 351-pass serialized receipts describe this actual dependency set, not c4fc8e7d dependencies. Intermittent inherited engine/fixture failure and timeout remain recorded and not green. No source changes, live operations, publication or Gate F approval." +} diff --git a/agents/darkwing/work/relaunch-activity/r1-review.md b/agents/darkwing/work/relaunch-activity/r1-review.md new file mode 100644 index 00000000..39b79a7c --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/r1-review.md @@ -0,0 +1,43 @@ +# #1512 R1 source review + +Filbert author. Frozen /tmp/relaunch-activity-r1-8ckPxKjN, seven files. +CANDIDATE.sha256 manifest +47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d. +Darkwing independently verified all seven working/frozen pins and eleven +inherited test pins, read RED.txt and reviewed the scoped scanner/CLI/UI delta. + +Darkwing approves the seven source changes. Relaunch notice requires positive +row and matching-registration PID liveness, matching session path, and strictly +newer finite timestamps. Connector registration is excluded. Existing historical +fields, state, Seen, attribution and reply gates remain unchanged. CLI and both +presentations replace current activity with the notice and label retained history. +This does not establish native process-incarnation authentication or resolve old +waiting/error state. + +Dewey independently APPROVED scoped UX on the same seven pins. Frozen targeted +scanner/browser suites 8/8 passed, four Chromium cases and 330 contrast samples. +Both fixture pages at 320px preserve history, show the notice without page overflow, +and resume normal presentation after new activity. Historical error label reviewed +in source; the browser fixture uses waiting, not error. No screenshot-based visual +review, real relaunch, live observation or Gate F evidence claimed. + +## Verification qualification and integration hold + +Darkwing's first exact serialized six-package run timed out at 120 seconds. +/tmp/darkwing-relaunch-r1-tests.txt records an inherited Discord engine test +failure, 'a run with tool turns settles once', before the hang. It is NOT green. +No test runner remained after the harness timeout. Detailed assertion output was +not captured by that reporter before the hang. + +An isolated TAP diagnostic of that engine case passed, using test-force-exit and +a 5-second test timeout. This is diagnostic only, not full-suite acceptance. +/tmp/darkwing-relaunch-engine-probe.txt. + +A full frozen TAP run with test-concurrency=1 and test-timeout=15000, WITHOUT +force-exit, passed 351/351, zero failed/cancelled/skipped, in 14.27 seconds. +/tmp/darkwing-relaunch-r1-diagnostic.txt. The first failure remains unexplained; +the later pass does not erase it or establish default-concurrency success. + +Hold integration while Rocko performs bounded read-only diagnosis of the inherited +engine failure. No author source correction requested yet. No live changes, +publication, operator acceptance or Gate F claimed. Preserve R1 and prior freezes. diff --git a/agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md b/agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md new file mode 100644 index 00000000..883d546e --- /dev/null +++ b/agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md @@ -0,0 +1,95 @@ +# #1512 R1 candidate re-run against HEAD, 2026-09-26 + +Requested by Sage over T3 on 2026-09-26. Integration was held because of the +row 21 engine test race, and `1685deb4` fixed that race. This record is new +evidence. The R1 files next to it are unchanged. + +## Snapshot + +- `/tmp/relaunch-activity-r1-rerun-oupRiqIC`: `git archive 21e3e908`, plus the + working `packages/control-board`, `packages/webui` and `packages/seat` + (node_modules excluded), plus a symlink to the root `node_modules`. +- `CANDIDATE.sha256` in the snapshot hashes to `47769fad…`, which is the R1 + candidate manifest. All 7 candidate pins match. +- 71 of the 89 R1 dependency pins are unchanged. The other 18 are + `packages/discord/**` and `scripts/test-discord.sh`, and they equal HEAD. + `engine-pi.mjs`, `engine.test.mjs`, `fake-pi.mjs` and `helpers.mjs` in the + snapshot are byte-identical to HEAD. +- Node v26.8.1. Test union: control-board, webui, seat, mosaic, ledger and + discord tests. The count rose from 351 at R1 to 397 because rows 23 to 25 + added Discord tests. + +## Results + +| Run | Flags | Result | Log (`/tmp/`) | sha256 prefix | +|---|---|---|---|---| +| A1 | `--test-concurrency=1 --test-timeout=15000` (R1 form) | 397/397, rc 0, 19.9 s | darkwing-1512-rerun-A1-bounded.txt | 8b34a64a660d5948 | +| A2 | same | 397/397, rc 0, 19.7 s | darkwing-1512-rerun-A2-bounded.txt | 5c8b96209f5b8b2c | +| A3 | same | 397/397, rc 0, 19.4 s | darkwing-1512-rerun-A3-bounded.txt | e9829f454bf2b63f | +| B1 | `--test-concurrency=1` | 397/397, rc 0, 19.0 s | darkwing-1512-rerun-B1-serial-notimeout.txt | bb6eaedf6250c410 | +| C1 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C1-default-concurrency.txt | 5200188e1bcd8b15 | +| C2 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C2-default-concurrency.txt | 1f244a4835000e31 | + +In C1 and C2, the same two tests failed, both in `packages/discord/tests/engine.test.mjs`: + +- Test 205 (line 100), "a prompt while streaming is held…": ENOENT on + `commands.jsonl`. +- Test 208 (line 146), "tool events from a run that outlived its timeout…": + `engine refused prompt: agent is streaming; specify streamingBehavior`. + +Both runs stopped making progress after every test had finished and the +`after()` hook had removed the temp root. The only thing left was a `fake-pi.mjs` +child. I sent that child SIGTERM (C1 pid 3636242, C2 pid 3668771). The file +then exited and the runner printed its summary. Apart from that, I didn't +touch either run. + +## Control: clean HEAD, no #1512 files + +Snapshot `/tmp/darkwing-head-21e3e908-zinKkQ` (`git archive 21e3e908` only). +Same union, default concurrency: + +| Run | Result | Log (`/tmp/`) | sha256 prefix | +|---|---|---|---| +| 1 | rc 124 at the 240 s cap, 178 ok, interrupted in seat and webui files | darkwing-head-default-1.txt | 799acad299d0b20e | +| 2 | rc 124 at the 240 s cap, same shape | darkwing-head-default-2.txt | e9028b28c02ffc86 | +| 3 | 369/370, rc 1, test 187 = the line 146 engine failure | darkwing-head-default-3.txt | fcd85bd474f2c210 | +| discord only | 162/162, rc 0 | darkwing-head-discord-only.txt | 5f068557caee7fc7 | +| seat + webui only | 21/21, rc 0 | darkwing-head-seat-webui.txt | 9952b57707577a92 | + +`engine.test.mjs` alone in the candidate snapshot passed 5 of 5 times, 11/11 +each run (`darkwing-engine-alone-{1..5}.txt`). + +Dewey was running `packages/webui/tests/` from the canonical checkout during +part of this window. That added load. It doesn't explain the HEAD control, and +the two engine failures reproduce without it. + +## Reading + +The #1512 candidate passes its R1 acceptance command 3 of 3 times at 397/397, +and it passes the serial run with no timeout. Every default-concurrency failure +is in committed `#1509` Discord engine code, and clean HEAD fails the same way. +I found nothing that implicates the seven candidate files. + +Two engine defects are exposed under load. They belong to `#1509`, not to #1512: + +1. **Test race plus leak, `engine.test.mjs:100`.** The test reads the fake's + command log 20 ms after the second prompt. Under load the fake hasn't written + the file yet, so the read fails with ENOENT. The test has no `try/finally`, + so `engine.stop()` never runs, the fake-pi child stays alive, and the test + file never exits. That is the hang. The fix is the pattern `1685deb4` + already uses elsewhere: wait with `until()` and stop in `finally`. Six other + tests in the file also stop without `finally`. +2. **Engine race, `engine-pi.mjs`, seen through `engine.test.mjs:146`.** + `busy` is `state.busy || pending.some((t) => !t.done)`. Suppose a turn times + out before the engine has read pi's `agent_start`. `failTurn` marks it done, + `state.busy` is still false, and `busy` reads false. The next prompt then goes + straight to pi, and pi refuses it because it's still streaming. This fails + closed: the prompt errors and nothing is misattributed. Production timeouts + are long, so this should be rare there. It's still a real race. One option is + to count a sent turn toward `busy` until pi's own end or settle event arrives, + not only until the client gives up on it. + +Rocko's finding C, a follow-up sent after a timeout, looks superseded. Since +`1685deb4`, the engine never sends a follow-up (held prompts), and the line 100 +test asserts that `streamingBehavior` is absent. Defect 2 is the timeout race +that remains. diff --git a/agents/darkwing/work/task-attribution/backend-live-verification.json b/agents/darkwing/work/task-attribution/backend-live-verification.json new file mode 100644 index 00000000..e5d736f2 --- /dev/null +++ b/agents/darkwing/work/task-attribution/backend-live-verification.json @@ -0,0 +1,18 @@ +{ + "at": "2026-09-15T00:11:38.757844+00:00", + "newBackend": [ + 3414098, + "76594640" + ], + "health": true, + "pageMatchesReviewedR2": true, + "allRowsHaveAttributionField": true, + "inapplicableAttributionNull": true, + "legacyRegisteredUnknownCount": 2, + "agentPaneIdentitiesUnchanged": true, + "connectorIdentityUnchanged": true, + "allRegistrationBytesUnchanged": true, + "rocko": [], + "rockoWritePerformed": false, + "blocker": "Rocko registration PID 185602 is dead; native context/lock PID is 3707667. No identity repair authorized." +} diff --git a/agents/darkwing/work/task-attribution/backend-restart.jsonl b/agents/darkwing/work/task-attribution/backend-restart.jsonl new file mode 100644 index 00000000..b2f13e04 --- /dev/null +++ b/agents/darkwing/work/task-attribution/backend-restart.jsonl @@ -0,0 +1,2 @@ +{"at": "2026-09-15T00:10:51.631297+00:00", "event": "owner-authorized-restart-intent", "oldBackend": [3769124, "72871926"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "registrationHashes": {"seats/repo/darkwing/registration.json": "4eda287e7f3bee8967052d90d5a15fb8bb61457e25991192bfaffcaba92c6075", "seats/repo/filbert/registration.json": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767", "seats/repo/rocko/registration.json": "2195325104fdaf13e5a8d351143f6c046144b9b99865d18b21d01564ac23b327", "seats/repo/dewey/registration.json": "92960db9f3f3d5c56c5cc79d23f39b3df3f1dcdeaae61bccbf0e62d98563aa6c", "seats/repo/researcher/registration.json": "77816e560918d2a9534ff2c97ef201605fbb5b6ef8e341d4dd948684082563fa"}, "configHash": "8e6b3a4049c7ab11ab6d842d974da0bcf07985cf4db988995436ed4b94b44338", "rockoWriteBlocked": {"registeredPid": 185602, "nativePid": 3707667}, "pinsVerified": 18} +{"at": "2026-09-15T00:10:51.650299+00:00", "event": "replacement-started", "newPid": 3414098, "oldExitedGracefully": true, "log": "/tmp/task-attribution-backend-gmaofryr.log"} diff --git a/agents/darkwing/work/task-attribution/baseline.json b/agents/darkwing/work/task-attribution/baseline.json new file mode 100644 index 00000000..32c0b7ca --- /dev/null +++ b/agents/darkwing/work/task-attribution/baseline.json @@ -0,0 +1,7 @@ +{ + "at": "2026-09-14T14:08:03.786Z", + "issue": 1511, + "exit": 4, + "explicitByRejected": true, + "liveFilesChanged": false +} diff --git a/agents/darkwing/work/task-attribution/live-test-envelope.md b/agents/darkwing/work/task-attribution/live-test-envelope.md new file mode 100644 index 00000000..68ea8982 --- /dev/null +++ b/agents/darkwing/work/task-attribution/live-test-envelope.md @@ -0,0 +1,34 @@ +# Proposed #1511 local test envelope + +Original proposal retained below. Jason authorized the backend update, completed +2026-09-15. Rocko's stale registration blocked that target before mutation. +Jason then explicitly authorized Filbert instead and confirmed Claude console +integration remains separate. The Filbert metadata update and live API checks +passed; operator visual acceptance is pending. Receipt: live-test.jsonl. +No Rocko registration or process was changed. + +- Reverify the approved R2 pins and current backend PID/start/command/config. + Record the five native agent identities and connector service identity. +- Restart only the control-board backend on loopback 7331, using its existing + command/config. Do not restart agents, WebUI or the Discord connector. +- Verify health and that the updated reader accepts existing records. Check + actual asset loading before claiming either presentation loaded R2. +- For the visual test only, update the repository-native Rocko registration's + task to '#1511 task attribution user test', with explicit setter darkwing. + Use the reviewed seat task command and explicit repo layout. First verify the + registration matches the intended native session; a mismatch blocks the write. +- Preserve the original registration privately outside the repository. Never + print its contents. Assert only task, taskSetBy and updatedAt change, with all + identity fields and startedAt preserved. No other registration may change. +- Verify API selection and ask Jason to inspect the 'set by darkwing' tag and + caller-claim wording. Legacy registered attribution remains unknown; inferred + and connector tasks remain not applicable. Agent/connector identities unchanged. + +Rollback: before any attribution write, an incompatible old reader can be restored +only from the retained prior artifact. After the one authorized write, restoring +an old reader requires restoring that record's original task metadata first, with +an exact unchanged-current-record check. If anything else changed, stop rather +than overwrite it. Keep the compatible reader while resolving the conflict. +No broad metadata migration or automatic rollback over concurrent edits. + +This envelope does not authorize publication or the later Filbert behavior task. diff --git a/agents/darkwing/work/task-attribution/live-test.jsonl b/agents/darkwing/work/task-attribution/live-test.jsonl new file mode 100644 index 00000000..6ce8ca45 --- /dev/null +++ b/agents/darkwing/work/task-attribution/live-test.jsonl @@ -0,0 +1,3 @@ +{"at": "2026-09-15T00:36:13.204764+00:00", "event": "owner-authorized-filbert-test-write", "target": "repo/filbert", "backup": "/tmp/task-attribution-filbert-k3o7zimk/registration-before.json", "nativeIdentity": [793364, "56603274"], "backend": [3414098, "76594640"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "beforeHash": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767"} +{"at": "2026-09-15T00:36:13.852595+00:00", "event": "verified", "agent": "filbert", "task": "#1511 task attribution user test", "taskSource": "registration", "taskSetBy": "darkwing", "afterHash": "1bc90238adcb0210fcd027d76ac2d87e292d5fe29e6892d4c3efad4fddb93edd", "onlyAllowedTaskMetadataChanged": true, "allOtherRegistrationsUnchanged": true, "agentAndConnectorIdentitiesUnchanged": true, "backendUnchanged": true, "operatorVisualAcceptance": "pending"} +{"at": "2026-09-15T00:47:20.173624+00:00", "event": "operator-visual-confirmed", "tableEvidence": "/tmp/pi-clipboard-afe5713e-77ed-4be6-9a24-d455178ed1f0.png", "inspectorConfirmed": "darkwing (as claimed by the caller, not verified)", "scope": "bounded local attribution delivery; publication and Gate F separate"} diff --git a/agents/darkwing/work/task-attribution/r1-manifest.sha256 b/agents/darkwing/work/task-attribution/r1-manifest.sha256 new file mode 100644 index 00000000..07274739 --- /dev/null +++ b/agents/darkwing/work/task-attribution/r1-manifest.sha256 @@ -0,0 +1,15 @@ +d289ec7d3a45c541346ece6b32908d75efe4ca0ad47df729bcf493d9bc9be718 packages/seat/src/seat.mjs +a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs +4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md +c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs +a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs +7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html +5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md +06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs +41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs +64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs +772d564b974de9d30df296d538e7878ceb2421f479ed07285456e7d43dfef8b9 packages/webui/src/public/app.js +0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs +d45b138bdb5a3eaf6cf2470b7b2beed16de0a635ad1868c8d25336b4040fe163 packages/webui/tests/browser.test.mjs +d39e5d1e1b7e3d4aa6689e20faabeee6691656e8661f866d98731912eb140d06 packages/webui/tests/browser-edge.test.mjs +c97fd047a970326c096a4771f9bd49496fb6ff244047ea5c1e59bdd7adaeefa2 packages/webui/tests/discord.test.mjs diff --git a/agents/darkwing/work/task-attribution/r1-review.md b/agents/darkwing/work/task-attribution/r1-review.md new file mode 100644 index 00000000..e48b4890 --- /dev/null +++ b/agents/darkwing/work/task-attribution/r1-review.md @@ -0,0 +1,46 @@ +# #1511 R1 combined review + +Author Rocko. Frozen candidate /tmp/task-attribution-r1-frozen-sRoqqr5m. +15-file manifest: r1-manifest.sha256, SHA256 +0185cc65c973f6d89c394e2e9fba67fccd794b8ad2be2720a8bb3700b2b018a3. +Darkwing independently matched every author pin against frozen and working files. +The author snapshot contains selected files, not a standalone runnable checkout. + +Both independent lanes returned before correction authorization. + +## Filbert backend verdict + +APPROVE AS SOURCE for exact R1, subject to separate presentation review. +Reported independent serialized seat/board/WebUI 141/141 in both working tree +and dependency-complete overlay /tmp/filbert-task-attribution-review-EaqP6CYc. +Log: TASK-ATTRIBUTION-TESTS.txt in overlay. Working launcher regressions 6/6, +/tmp/filbert-task-attribution-launchers.txt. Additional invalid-environment and +control-character probes passed. All 15 pins matched after tests. + +Confirmed optional v1 compatibility, precedence/refusal rules, preservation of +other fields and task-selected attribution only. No backend blocker. +Nonblocking comment correction: bounded identifier syntax does not guarantee +absence of private identifiers. Numeric IDs and email-like names still fit. +Remove that inaccurate privacy claim in a separately pinned R2. + +## Dewey UX verdict + +REQUEST CHANGES, R1-U1. WebUI inspector maps null/inapplicable attribution to +unknown, conflating a transcript/connector task with a legacy registered task +whose setter is unknown. Preserve null as not applicable or a missing-value +marker. Keep unknown for a selected legacy registration. Add assertions for +legacy unknown, transcript/Discord null, and transition from a known setter +without stale attribution. Existing edge expectation currently enshrines the bug. + +Dewey independently ran matching working-tree browser, browser-edge and Discord +suites, 3/3 passed, 330 contrast samples. Frozen/working pins matched before and +after. No screenshots, live observation or full backend verdict claimed. +Escaping, placement and reply boundaries otherwise passed his scoped review. + +## Disposition + +R1 is not approved for integration. Preserve its snapshot and manifest. Rocko +corrects R1-U1 and the inaccurate comment, reruns applicable checks, and supplies +R2 with changed/inherited pins. Both review lanes then verify the exact R2. +The missing docs/TOOLS.md usage flag is required additive documentation; preserve +other owners' dirty edits when adding it. No Gate F, live change or publication. diff --git a/agents/darkwing/work/task-attribution/r2-manifest.sha256 b/agents/darkwing/work/task-attribution/r2-manifest.sha256 new file mode 100644 index 00000000..21df8cdc --- /dev/null +++ b/agents/darkwing/work/task-attribution/r2-manifest.sha256 @@ -0,0 +1,15 @@ +eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs +a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs +4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md +c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs +a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs +7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html +5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md +06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs +41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs +64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs +954d98fa2e6b264ed751857f0b76bb36c966c1ef59e4b091eb0b5351e84783d7 packages/webui/src/public/app.js +0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs +01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs +b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs +24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs diff --git a/agents/darkwing/work/task-attribution/r2-review.md b/agents/darkwing/work/task-attribution/r2-review.md new file mode 100644 index 00000000..c9ec243a --- /dev/null +++ b/agents/darkwing/work/task-attribution/r2-review.md @@ -0,0 +1,41 @@ +# #1511 R2 source approval + +Author Rocko. Frozen /tmp/task-attribution-r2-frozen-3PqORMJP. +15-file manifest r2-manifest.sha256, SHA256 + d4c963952b3f2b0e1c99133b3fe3fefa4c1883b79a009dc1f8d20ef58b46ca16. +Darkwing verified all working/frozen pins and all retained R1 pins. Exactly five +changed files, ten inherited. R1 remains unchanged. + +Filbert independently APPROVED backend/schema/tests as source. He verified +working/frozen/overlay pins after 141/141 serialized seat/board/WebUI checks in +both working tree and /tmp/filbert-task-attribution-r2-review-v6KDM7pv. +Log: R2-TESTS.txt there. Privacy-syntax comment finding closed. +He also approved only Darkwing's additive TOOLS usage patch, SHA256 +86bcba3c2889b12151aefa1fa266fff81bb91126450b2deeb7ca519869ff2253. +Other dirty TOOLS changes are not adopted or approved by that verdict. + +Dewey independently APPROVED scoped source UX. R1-U1 CLOSED. Null stays not +applicable, legacy registration unknown stays unknown, and transition assertions +remove prior setter names. Matching working-tree browser/browser-edge/Discord +checks 3/3 passed, 330 contrast samples. No screenshots or live visual observation. +He rechecked all 15 pins after tests. Dependency context was the working tree. + +Darkwing additionally ran the broader working-tree command: + + node --test --test-concurrency=1 packages/control-board/tests/ packages/webui/tests/ packages/seat/tests/ packages/mosaic/tests/ packages/ledger/tests/ packages/discord/tests/ + +344/344 passed, exit 0. Log /tmp/darkwing-task-attribution-r2-tests.txt. +This is serialized local verification, not CI or default-concurrency success. + +## Reader compatibility and remaining gate + +Filbert and Darkwing independently reproduced the old strict-v1 reader rejecting +a new taskSetBy field. New readers accept both old and new records. +Darkwing's synthetic-only receipt is reader-compatibility.json; no live record +was used for that probe. Optional v1 is backward-read compatible, not old-reader +forward compatible. Update cached readers before any live attribution write. +Do not roll an old reader back over new records without an approved restoration. + +Source approval is complete. Live reader update, operator testing and publication +remain separate. Gate F and the Filbert stale-relaunch assignment are not started +or claimed by these approvals. No agent or connector restart is authorized here. diff --git a/agents/darkwing/work/task-attribution/reader-compatibility.json b/agents/darkwing/work/task-attribution/reader-compatibility.json new file mode 100644 index 00000000..b8b5e7a1 --- /dev/null +++ b/agents/darkwing/work/task-attribution/reader-compatibility.json @@ -0,0 +1,8 @@ +{ + "at": "2026-09-14T14:28:14.532Z", + "oldReaderRejectsNewField": true, + "newReaderAcceptsOldAndNew": true, + "syntheticOnly": true, + "liveRegistrationReadsOrWrites": 0, + "requirement": "Update cached readers before first live attribution write; old-reader rollback is incompatible with new records." +} diff --git a/agents/darkwing/work/task-attribution/tools-usage.patch b/agents/darkwing/work/task-attribution/tools-usage.patch new file mode 100644 index 00000000..bfcdc718 --- /dev/null +++ b/agents/darkwing/work/task-attribution/tools-usage.patch @@ -0,0 +1,5 @@ +--- a/docs/TOOLS.md ++++ b/docs/TOOLS.md +@@ -137 +137 @@ +-scripts/mosaic seat task [--layout repo|fleet|unknown] [--config PATH] ++scripts/mosaic seat task [--by NAME] [--layout repo|fleet|unknown] [--config PATH] diff --git a/agents/darkwing/work/tree-manifest-2026-09-26.md b/agents/darkwing/work/tree-manifest-2026-09-26.md new file mode 100644 index 00000000..1f531318 --- /dev/null +++ b/agents/darkwing/work/tree-manifest-2026-09-26.md @@ -0,0 +1,161 @@ +# Darkwing dirty-tree manifest, 2026-09-26 + +Requested by Sage (lead) over T3 on 2026-09-26. Nothing listed here is +committed. Sage reviews this list before anything lands, because AGENTS.md, +QUEUE.md and SESSIONS.md mix edits from both of us. + +Baseline: HEAD `21e3e908` (the comms commit), branch `refactor`. All seven +suites passed on this tree at 19:31Z: config 24, task 90, foundation 43, +conductor 17, release 14, auth 15, discord 63. + +"Pin match" means the working file's SHA-256 equals a hash recorded in an +approval I can still read. The frozen `/tmp` review copies from 09-13 to 09-15 +are gone. The pins survive in `agents/darkwing/work/*` and in my Pi session log +`.pi/state/darkwing/sessions/2026-09-12T16-44-19-503Z_*.jsonl`. + +Excluded on Sage's instruction: `agents/dewey/work/wui/**`, the 23 `aws-*` +directories and other untracked `skills/**`. Also excluded, because they are +Sage's own work: `agents/sage/work/coordination/**`. + +## 1. Code: rows 18, 22, #1511 and #1512 are one cumulative state + +`packages/control-board`, `packages/webui` and `packages/seat` carry four +approved rows layered on each other. `scan.mjs`, `page.html`, `README.md` and +`app.js` contain edits from several rows, so the rows cannot be committed +separately without rebuilding intermediate states that no longer exist on disk. +The working tree equals the #1512 R1 frozen tree exactly for these three packages. +Recommendation: one commit covering all four rows, citing each receipt. + +| Path | Rows | Pin match | Ready | +|---|---|---|---| +| packages/control-board/README.md | 22, #1511, #1512 | #1512 R1 candidate | with #1512 | +| packages/control-board/src/scan.mjs | 18, 22, #1511, #1512 | #1512 R1 candidate | with #1512 | +| packages/control-board/src/cli.mjs | 18, #1512 | #1512 R1 candidate | with #1512 | +| packages/control-board/src/page.html | 18, #1511, #1512 | #1512 R1 candidate | with #1512 | +| packages/control-board/src/serve.mjs | 18 | #1512 R1 dependency | with #1512 | +| packages/control-board/src/discord.mjs (new) | 18 | #1512 R1 dependency | with #1512 | +| packages/control-board/tests/scan.test.mjs | 22, #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/control-board/tests/serve.test.mjs | 18, 22, #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/control-board/tests/attention.test.mjs (new) | 22 | #1512 R1 dependency | with #1512 | +| packages/control-board/tests/attention-flow.test.mjs (new) | 22 | #1512 R1 dependency | with #1512 | +| packages/control-board/tests/discord.test.mjs (new) | 18 | #1511 R2 and #1512 dep | with #1512 | +| packages/control-board/tests/relaunch.test.mjs (new) | #1512 | #1512 R1 candidate | with #1512 | +| packages/webui/src/public/app.js | #1511, #1512 | #1512 R1 candidate | with #1512 | +| packages/webui/tests/fixture.mjs | 22, #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/webui/tests/browser.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/webui/tests/browser-edge.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/webui/tests/discord.test.mjs (new) | 18 | #1511 R2 and #1512 dep | with #1512 | +| packages/webui/tests/relaunch.test.mjs (new) | #1512 | #1512 R1 candidate | with #1512 | +| packages/seat/README.md | #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/seat/src/cli.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/seat/src/seat.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 | +| packages/seat/tests/seat.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 | + +Receipts: + +- Row 18 (#1509 board row): exact R3 source approvals from Darkwing and Dewey + in Gitea comment 26257, R3 manifest `254403b8…`. Jason accepted the visual + test ("parfait"). `agents/darkwing/work/discord-board/r3-backend-review.json`. +- Row 22 (#1503 attention): Filbert approved exact R1, comment 26248, R1 + manifest `e40b58ec…`. Live restart receipt 26249. +- #1511 (row 6 code phase): R2 approved by Filbert (backend) and Dewey (UX), + manifest `d4c96395…`. Operator confirmed table and inspector. + `agents/darkwing/work/task-attribution/r2-review.md`. +- #1512 (row 6 pilot): Darkwing backend and Dewey UX approved R1, candidate + manifest `47769fad…`. The seven working files reproduce that manifest hash + exactly. 71 of 89 dependency pins are unchanged. The other 18 are + `packages/discord/**` and `scripts/test-discord.sh`, which moved with + committed rows 23–25. Integration was held on the row 21 engine test race. + The re-run against HEAD is in + `agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md` (new, ready + with #1512 after your review). + +## 2. Row 16: internal development bootstrap (#1510) + +Filbert's R1 approval (receipt 26204) pins nine files. Researcher's live check +is receipt 26216. + +| Path | Pin match | Ready | +|---|---|---| +| agents/researcher/SOUL.md | R1 pin `aeb422bc` | yes | +| agents/researcher/CONTEXT.md (new) | R1 pin `caa71d37` | yes | +| agents/researcher/README.md (new) | R1 pin `e1b494f6` | yes | +| agents/researcher/launch.sh (new) | R1 pin `825b4547` | yes | +| agents/researcher/validate-sessions.mjs (new) | R1 pin `0420f136` | yes | +| scripts/test-darkwing-launch.mjs | R1 pin `d79d4611` | yes | +| docs/plans/2026-09-13_internal-development-bootstrap.md (new) | R1 pin `6ef48e9a` | yes | +| AGENTS.md | R1 pin `1f2ca52e` no longer matches | shared, see section 5 | +| agents/README.md | R1 pin `089b1c2b` no longer matches | shared, see section 5 | + +### agents/sage launch files + +`agents/sage/{launch.sh,README.md,SOUL.md,validate-sessions.mjs,CONTEXT.md}` +are not in the row 16 R1 pin set. SESSIONS.md (2026-09-09 01:34 UTC) records +that Codex added them on Jason's request ("Owner-corrected Sage location"). I +found no review receipt for them in my records. Row 16 covers five agents: +Darkwing, Dewey, Filbert, Rocko and Researcher. `agents/sage/CONTEXT.md` was +last written today at 14:30 local, after Jason's lead ruling, which suggests +Sage edited it. I list these files but do not claim them. They need a review +or Jason's word before they are committed. Your call. + +## 3. docs/TOOLS.md (#1511) + +The whole working diff against HEAD is the one-line `--by` usage flag. Its +changed lines are identical to the approved patch +`agents/darkwing/work/task-attribution/tools-usage.patch` (SHA-256 +`86bcba3c…`, Filbert approved in #1511 R2). Ready, with #1511. + +## 4. Briefs and evidence records + +| Path | Row | Ready | +|---|---|---| +| docs/plans/2026-09-13_board-attention-status.md | 22 | yes, brief | +| docs/plans/2026-09-14_discord-board-row.md | 18 | yes, brief | +| docs/plans/2026-09-14_task-attribution.md | #1511 | yes, brief | +| docs/plans/2026-09-15_relaunch-activity.md | #1512 | yes, brief | +| agents/darkwing/work/board-attention/* (3 files) | 22 | yes | +| agents/darkwing/work/discord-board/* (8 files) | 18 | yes | +| agents/darkwing/work/internal-development-bootstrap/* (2 files) | 16 | yes | +| agents/darkwing/work/task-attribution/* (11 files) | #1511 | yes | +| agents/darkwing/work/relaunch-activity/* (8 files, including today's re-run record) | #1512 | yes | +| agents/darkwing/work/tree-manifest-2026-09-26.md (this file) | — | after Sage's review | + +I scanned the evidence records and briefs for credential shapes (GitHub, +GitLab, Slack, OpenAI-style keys, bearer tokens, private keys, Discord bot +tokens). No hits. They do hold PIDs, loopback ports and `/tmp` paths, which +are the evidence. + +### Older coordination records, 2026-09-08 to 09-10 + +29 untracked files under `docs/plans/reviews/`: +`2026-09-08_publication-trial-*` (21, #1497), `2026-09-08_skill-launcher-*` +(2, #1498), `2026-09-09_bma-foundation-pointer-handoff.md`, and +`2026-09-10_m20-*` (9, #1499/#1500). These are my review requests, snapshots +and coordination notes from before the native relaunch. None are in the +published sets `29c1defe` (#1497) or `3daee5ad` (#1500), and I can't find the +reason they were excluded. The credential scan found nothing. Not ready until +I've read them for anything that was kept out on purpose. + +## 5. Shared files, both of us + +| Path | Mine | Hazard | +|---|---|---| +| AGENTS.md | row 16 roster, row 22 attention convention | Sage's lead ruling edits today | +| agents/README.md | row 16 roster | Sage's edits today | +| docs/plans/QUEUE.md | rows 1, 6, 16, 18, 22 (row 5 is Dewey's) | Sage replaced my coordinator line; don't revert | +| docs/plans/CURRENT.md | row 6, 18, 22 narrative | Sage's 09-26 entry | +| docs/plans/DEFERRED.md | #1512 engine intermittency, row 18 concurrent hangs, Rocko usage notice | the #1512 entry needs the re-run outcome appended | +| docs/SESSIONS.md | my session lines | append-only, mixed authors | +| BUILD-LOG.md | entries for rows 16, 18, 22, #1511, #1512; Dewey's #1507 CHAT entries are also uncommitted | see below | + +**BUILD-LOG.md would lose committed history if committed from the working +copy.** The working file is missing 119 lines that are in HEAD. They are the +three phase entries committed with rows 23, 24 and 25 (`1685deb4`, `1949ed8d`, +`43d7574d`: 36, 45 and 53 added lines). The row 23–25 commits seem to have been +built from HEAD plus their own hunks, leaving the working file behind. Staging +the working BUILD-LOG.md as it is would delete those entries, which breaks the +append-only rule. The fix is to rebuild the working file as HEAD plus the +uncommitted entries, in order. Nothing is lost. The same check on SESSIONS.md +and DEFERRED.md found zero HEAD lines missing. CURRENT.md (6), QUEUE.md (10), +AGENTS.md (4), agents/README.md (3) and TOOLS.md (1) show small differences +that look like intentional row edits. Check them before committing. diff --git a/agents/researcher/CONTEXT.md b/agents/researcher/CONTEXT.md new file mode 100644 index 00000000..bbfde391 --- /dev/null +++ b/agents/researcher/CONTEXT.md @@ -0,0 +1,34 @@ +# Researcher native development context + +You are Researcher, a member of the repository-native Mosaic Stack development +team coordinated by Darkwing. Work in `/mnt/storage/src/mosaic-stack`. + +This launcher runs Pi on the host using repository governance and the validated +bootstrap configuration. It is not a managed fleet seat or sandbox. Host tools +are available for authorized research, evidence and fixture work; their presence +never grants access to unrelated work, secrets or live services. + +Read `docs/plans/QUEUE.md` and the applicable owner-approved task before acting. +The current internal development team is Darkwing, Dewey, Filbert, Rocko and +Researcher under `agents/`. Route development coordination to Darkwing. Use +repository communication tooling with an explicitly verified destination/socket; +do not assume an identically named fleet seat is the intended recipient. + +Do not modify `~/.mosaic` launchers, agents, provisioning or other state for this +bootstrap assignment. Do not start or stop live fleet processes, migrate sessions, +change policy or widen access. Work on repository source and offline evidence. +Use your performing-agent identity for authorized issue/Git actions, never an +implicit operator identity. Missing access is a boundary, not a fallback license. + +Distinguish cited sources, hypotheses, synthetic checks and observed behavior. +Preserve other authors' files. Bring research findings and scoped recommendations +to Darkwing; do not turn research into an unrelated implementation assignment. + +The installed goal extension owns any operator-set goal lifecycle. Follow its +actual tool schema and use ms-proactive-agent for authorized continuation, not a +second goal loop. This launcher starts no goal or assignment. Conversation state +is per-agent under the checkout's `.pi/state/researcher`; normal launch resumes +validated history and `--fresh` preserves prior conversations. + +Context is captured at launch. Source changes do not update an existing process's +injected instructions; no automatic restart is authorized by editing this file. diff --git a/agents/researcher/README.md b/agents/researcher/README.md new file mode 100644 index 00000000..89154135 --- /dev/null +++ b/agents/researcher/README.md @@ -0,0 +1,33 @@ +# Researcher + +Repository-native technical research seat, coordinated by Darkwing. Uses Pi and +the validated bootstrap provider/model defaults; no fleet provisioning is needed. + +From `/mnt/storage/src/mosaic-stack`: + +```sh +agents/researcher/launch.sh --check +agents/researcher/launch.sh +agents/researcher/launch.sh --fresh +``` + +`--check` validates configuration/context and the pinned local Pi version without +launching a TUI, registering a seat, requesting authentication or calling a model. +Normal launch registers the repository seat through `scripts/mosaic`, then uses +the existing host-development helper. It resumes validated per-agent conversation +state under `.pi/state/researcher`; `--fresh` starts another conversation without +deleting history. Existing launcher locking prevents duplicate native instances. + +This is the same explicit native mode as Darkwing/Dewey/Filbert, not a fallback +from a container policy refusal. The retained `agent.json` describes the separate +managed-mode definition; no managed role or capability policy is changed here. + +Bootstrap configuration remains authoritative. Missing config, context or pinned +dependencies fails closed. No configuration or credentials are copied from a fleet +seat. No files under `~/.mosaic` are modified by this source setup. Use the existing +bootstrap/build workflow documented in the repository, rather than installing an +unversioned engine or modifying a home-directory launcher. + +No engine was launched to verify this source change. Offline launcher tests use a +fake Pi and isolated registration/session directories. Live startup and external +provider authentication are separate observations, not implied by passing tests. diff --git a/agents/researcher/SOUL.md b/agents/researcher/SOUL.md index f699d335..a29b2647 100644 --- a/agents/researcher/SOUL.md +++ b/agents/researcher/SOUL.md @@ -1,5 +1,8 @@ # SOUL - researcher -You are the researcher seat of the Mosaic fleet. You are curious, methodical, -and precise. You cite what you know, admit what you do not, and never guess -when you can verify. +You are Researcher, the technical researcher on Mosaic Stack's internal +repository-based development team. Darkwing coordinates assignments and review. +You are curious, methodical and precise. Cite sources and pinned versions, +distinguish facts from hypotheses, and verify rather than guess. Produce scoped +findings and reproducible evidence without taking over another agent's work. +Follow the current repository assignment, not an assumed fleet mission. diff --git a/agents/researcher/launch.sh b/agents/researcher/launch.sh new file mode 100755 index 00000000..c7db6f85 --- /dev/null +++ b/agents/researcher/launch.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Researcher's repository-native development entry point. +set -euo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then + exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi researcher -- "$@" +fi +exec "$REPO/scripts/agent.sh" --host-dev researcher "$@" diff --git a/agents/researcher/validate-sessions.mjs b/agents/researcher/validate-sessions.mjs new file mode 100644 index 00000000..fed8897b --- /dev/null +++ b/agents/researcher/validate-sessions.mjs @@ -0,0 +1,21 @@ +// Refuse damaged history before Pi's --continue can silently skip it. +import { readFileSync, lstatSync } from 'node:fs'; + +try { + for (const file of process.argv.slice(2)) { + if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`); + const lines = readFileSync(file, 'utf8').trim().split('\n'); + const entries = lines.map((line) => JSON.parse(line)); + const header = entries[0]; + if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id || + typeof header.version !== 'number' || typeof header.cwd !== 'string' || + !Number.isFinite(Date.parse(header.timestamp)) || + entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) { + throw new Error(`invalid session structure: ${file}`); + } + if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`); + } +} catch (error) { + console.error(`researcher: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`); + process.exit(1); +} diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0d4b49ac..5f993af0 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -134,7 +134,7 @@ Push is never automatic. ```bash scripts/mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...] -scripts/mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +scripts/mosaic seat task [--by NAME] [--layout repo|fleet|unknown] [--config PATH] ``` Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and diff --git a/docs/plans/2026-09-13_board-attention-status.md b/docs/plans/2026-09-13_board-attention-status.md new file mode 100644 index 00000000..ce0bb5e3 --- /dev/null +++ b/docs/plans/2026-09-13_board-attention-status.md @@ -0,0 +1,42 @@ +# Board attention status correction + +Jason accepted discovery/reply tests 1-4, then reported Researcher still showing +waiting after its completed reply. He approved separating idle from explicit +requests for human input. This is a #1503 acceptance defect, not new chat scope. + +Darkwing authors; internal Filbert independently reviews. Baseline observed on +refactor: d9745a451004699256af32c2145df40df9f5b4a5. Preserve existing shared edits +and Dewey's UI work. Source scope: control-board scanner, tests and README, +AGENTS.md status guidance, the downstream packages/webui/tests/fixture.mjs waiting +case and scoped tracking records. Dewey confirmed no overlapping ownership. No home-directory or +fleet changes, agent restarts, new public JSON fields, approval/control authority, +text classification service or unrelated UI changes. + +## Rule and evidence + +Completed normal assistant replies are idle. A completed assistant reply requests +human attention only when its first nonblank text line is exactly prefixed +`Input needed: ` followed by a nonempty request. This readable status convention +is explicit, not a guess based on questions, sentiment, or turn completion. +Quoted examples, user/tool messages and thinking blocks do not signal attention. +Tool activity and errors retain their existing precedence. The latest message +wins; a later ordinary completed reply clears the previous request. + +The signal is advisory display state, never permission to perform an action. +Agents use it only for Jason's decision/input, not a wait on another agent or a +routine completion. Existing conversations without the signal become idle after +normal completion. Earlier unstated human blockers cannot be inferred safely. + +Seen acknowledges a specific waiting/error event. It hides that event from the +attention list but does not resolve a genuine input request. New explicit requests +reappear; ordinary completions do not. No change to existing API enum or fields. + +Acceptance: reproduce BOARD_REPLY_OK incorrectly becoming waiting, test completed +replies/explicit requests/quoted and stale markers/tool activity/errors/Seen, +run scanner/server and relevant downstream regressions, and obtain exact-candidate +independent review. Verify the corrected classification against Researcher's +actual session without editing it. Running-board replacement and operator retest +must be reported separately from source tests; no unattended service restart. + +Before implementation: new attention tests reproduce two failures, including +BOARD_REPLY_OK being classified as waiting. One precedence test already passes. diff --git a/docs/plans/2026-09-13_internal-development-bootstrap.md b/docs/plans/2026-09-13_internal-development-bootstrap.md new file mode 100644 index 00000000..5dfe2f34 --- /dev/null +++ b/docs/plans/2026-09-13_internal-development-bootstrap.md @@ -0,0 +1,60 @@ +# Repository-native development bootstrap + +Tracking: #1510, created and read-back verified as Darkwing. Branch `refactor`. +Observed shared HEAD after initial checks: `dc5902aafded4042b6a919d9a6aec9f78febe935`. +Other owners have dirty shared docs and active work; they are not adopted here. + +Owner-authorized source work. Jason directs Darkwing to coordinate development +through Darkwing, Dewey, Filbert, Rocko and Researcher in the canonical checkout, +`/mnt/storage/src/mosaic-stack`. No `~/.mosaic` launcher/provisioning changes. +No live fleet stop or migration. This assignment supersedes fleet expansion as +the current development priority, not the retained foundation's worker policies. + +## Delivery boundary + +Darkwing authors; Filbert independently reviews. Keep other agents' existing +changes and completed records, including Dewey's UI work and Sage's DYOR scope. + +Allowed source work is the internal team documentation and Researcher's missing +native entry/context/recovery files, the existing native launcher regression +suite, and scoped queue/current/session/build tracking. Preserve agent.json and +roles/contracts; explicit host mode does not widen a managed worker's authority. +No new engine installation, credentials, home-directory launcher, native process +launch, fleet task dispatch, deployment or unrelated runtime change. + +Researcher uses the validated configured Pi model, the smallest consistent +choice rather than a new provider/model decision. Darkwing/Dewey defaults, +Filbert's pinned Codex configuration and Rocko's Sonnet wrapper are unchanged. + +## Acceptance + +1. All five internal agents have repository launch entry points and current + coordination guidance. Researcher has the same per-agent validation and + recovery behavior as the existing native Pi seats. +2. Offline fake-engine tests cover context, registration, first/fresh/resume, + malformed history, missing configuration/resources and duplicate-instance + refusal. All data and registrations stay in test fixtures. +3. Real launcher `--check` succeeds without a TUI, authentication/model request, + seat registration or home-directory writes. This is configuration evidence, + not actual engine/provider interoperability. +4. No managed role/config/contract changes, no edits to `~/.mosaic`, and no + migration or interruption of live seats. Existing unrelated diffs remain. +5. Independent exact-candidate review before claiming source readiness. No + deployment or unapproved publication follows automatically from this task. + +## Current source checkpoint + +Researcher previously had only a managed agent.json and fleet-labelled SOUL. +Added native launcher, CONTEXT, README and the existing session validation +behavior; changed its persona to the internal research role. Added Researcher to +the existing isolated Pi launcher suite. All four Pi seats and Rocko's offline +suite pass, six tests total. No real engine launched. + +All five real launcher `--check` calls pass with no TUI, authentication request, +model call or seat registration. Next: obtain internal Filbert's independent +exact-candidate review. New launches read the +updated source context; existing running sessions do not reload it implicitly. + +The approved CHAT-01C local commit and its Git receive-pack authorization gate +remain separate. Do not use this assignment to modify external provisioning or +publish unrelated local commits. diff --git a/docs/plans/2026-09-14_discord-board-row.md b/docs/plans/2026-09-14_discord-board-row.md new file mode 100644 index 00000000..6a7753f8 --- /dev/null +++ b/docs/plans/2026-09-14_discord-board-row.md @@ -0,0 +1,65 @@ +# Discord connector board row + +Current authorized queue item: row 18, #1509, pilot plan section 11. Jason +assigned this to Darkwing and now explicitly requires automatic continuation to +the next authorized item after each accepted iteration. This starts row 18, +not unrelated gated work. Preserve the accepted row-22 attention correction. + +## Ownership and scope + +Filbert implements in the canonical checkout on refactor. Darkwing independently +reviews the exact candidate; Dewey reviews any visible presentation change. +Single writer for this implementation: Filbert. Existing dirty files remain +owned by their authors and must not be reset, adopted or overwritten. + +Allowed implementation paths: packages/control-board source/tests/README and +minimal packages/webui source/tests changes needed to display the connector +and refuse replies. No connector source, bindings, secrets, launchers, systemd +units, live service changes or files under ~/.mosaic. No commits or publication +by the implementer. Darkwing owns shared tracking records. + +## Existing contract to implement + +The original accepted connector brief supplies the interface: + +- Discover one row per /discord/.json, with 0600 regular + non-symlink files. Project fleet, agent ` (discord: )`. + Validate only safe name/seat identity for discovery; never dereference or + expose token paths, Discord/user/channel IDs, or the rest of the binding. +- Sessions are under /sessions/discord-. +- Reuse the connector's read-only readPid/ownerState identity checks from + packages/discord/src/journal.mjs. A positive live PID plus matching start + tick and boot ID is necessary. Missing, corrupt, dead or unverifiable owners + cannot be reported live. Do not signal, recover, unlock or rewrite anything. +- Show STOP presence as braked without interpreting its private contents. + Liveness and braking must be distinguishable. Existing completed-reply idle + semantics still apply to session activity. +- Refuse board replies server-side for connector rows even if a stale or + forged registration claims a tmux destination. The UI must not offer reply. +- Avoid filesystem traversal, symlink reads and disclosure of private binding + fields. A malformed binding must not manufacture an actionable row. Report + discovery failures safely rather than dumping private content or credentials. + +Daily counters are optional in the original brief and excluded from this first +row. No new ingress, controller, Discord API calls or engine/model calls. + +## Acceptance and handoff + +Implement and test discovery/privacy, positive and negative process identity, +STOP/braked display, ordinary session state, server-side reply refusal and +unchanged ordinary-agent replies. Test both board and WebUI integration with +isolated fixtures. Preserve the accepted attention regression coverage. + +Return an exact frozen candidate, changed paths and reproducible test results. +The author does not self-approve. Darkwing and Dewey return scoped independent +verdicts before integration. Source tests do not prove live service transitions. +A read-only observation of the running connector is allowed after source review; +no live service stop/brake/restart or backend replacement is inferred. Existing +protected-operation and operator-acceptance gates remain in force. + +## Continuation correction + +Darkwing previously said he was continuing to this item but performed no action +before ending the turn. Jason called out the violation. This entry records the +actual start and ownership; a promise or dispatch is not completion. While the +author works, Darkwing prepares independent acceptance and reconciles records. diff --git a/docs/plans/2026-09-14_task-attribution.md b/docs/plans/2026-09-14_task-attribution.md new file mode 100644 index 00000000..89c9ad7c --- /dev/null +++ b/docs/plans/2026-09-14_task-attribution.md @@ -0,0 +1,45 @@ +# Task attribution, row 6 code phase + +Jason accepted row 18 and has explicitly required automatic continuation after +accepted iterations. This starts the next already-briefed item, row 6 of QUEUE, +under the existing control-board MVP plan's Piece 5. The newer standing start +instruction replaces that piece's historical Sunday start-message scheduling; +its ordering and protected-action gates remain. Use repository-native agents, +not fleet launchers. Rows 9-13 still wait for row 6 completion. + +## Source assignment + +Rocko implements the code phase from the internal agents/rocko profile in the +canonical checkout, refactor. Filbert independently reviews backend/schema/tests; +Dewey independently reviews presentation. Darkwing coordinates and owns tracking. +The existing Rocko native process uses the older tmux socket; this is not a +managed fleet worker assignment or authority to change its launcher. + +Implement the already-approved taskSetBy requirement: mosaic seat task records +an explicit --by NAME, otherwise MOSAIC_AGENT_NAME, otherwise unknown. Board +serialization and both current board presentations display it by the task source. +This is caller-supplied attribution, never authenticated identity or authorization. + +Scope: packages/seat source/tests/docs, packages/control-board source/tests/docs, +and minimal WebUI presentation/tests. Preserve existing attention and Discord-row +changes exactly except the necessary additive integration. No launcher, connector, +role, credential, home, live registration or service changes; no commits/push by +the author. Use isolated fixtures, not actual seat task mutation during development. + +Acceptance: +- Explicit --by wins over environment; absent/empty environment gives unknown. + Invalid explicit input refuses before writing. Values are bounded and escaped. +- Existing registration records without taskSetBy still load, showing unknown. + No breaking registration version change or implicit migration of old files. +- Preserve unrelated registration fields and original startedAt on task updates. +- Display attribution only for the task actually selected: no stale registration + attribution on a transcript-derived task, and no borrowing native attribution + for a Discord connector's fixed task. Attribution never enables replies/access. +- Isolated CLI/schema/scanner/browser regressions, exact frozen handoff and + independent review. Existing accepted behavior remains covered. + +This code phase precedes the plan's specified first Filbert behavior assignment, +Relaunched seat shows the old last message. Do not start that concurrently. +Gate F and its human-message evidence are not claimed by this code phase; record +actual future dates, not the old plan's anticipated 2026-09-13 measurement. +No live backend replacement or publication follows automatically from source tests. diff --git a/docs/plans/2026-09-15_relaunch-activity.md b/docs/plans/2026-09-15_relaunch-activity.md new file mode 100644 index 00000000..c83d68b3 --- /dev/null +++ b/docs/plans/2026-09-15_relaunch-activity.md @@ -0,0 +1,35 @@ +# Relaunch activity, row 6 behavior assignment + +Jason confirmed the #1511 table tag and inspector wording. The code phase's +bounded local delivery is accepted. Under his standing continuation mandate, +start Piece 5's already-specified first Filbert assignment from the control-board +MVP plan: Relaunched seat shows the old last message. + +Filbert is sole source author. Darkwing independently reviews backend and tests; +Dewey reviews presentation. Work in the canonical refactor checkout. Preserve +accepted attention, Discord and task-attribution changes and all other owners' +changes. Frozen prior candidates remain unchanged. + +Scope: minimal packages/control-board scanner/presentation/tests/docs changes +and corresponding WebUI presentation/tests when needed. If a live matching +registration's startedAt is newer than recorded session activity, show +'relaunched at X, no messages since' instead of presenting an old message as +current activity. Preserve historical transcript data. After new session activity, +normal presentation resumes. Stale/dead registrations must not assert a relaunch. +No effect on connector owner identity or fixed connector task. + +Prove the old behavior first with isolated fixtures. Cover newer launch vs old +activity, equality, subsequent new activity, stale registration and unknown/missing +activity. Preserve attention/Seen, task attribution and reply authorization. +Do not silently redesign those semantics. Return an exact frozen candidate, +changed/inherited pins and reproducible tests for both independent reviewers. + +No launcher, Claude discovery, credential, role, home, live registration or +service changes by the author. No commits/push or shared tracking edits by the +author. No real seat relaunch is needed for the source fixture tests. Darkwing +owns tracking and any separately authorized integration. + +Publication remains a separate gate, including prior unpublished dependencies. +Do not claim Gate F or issue closure from fixture tests. Its eventual evidence +must use the actual assignment dates and demonstrate no direct Jason-to-Filbert +work messages; never backdate the old plan's anticipated 2026-09-13 metric. diff --git a/packages/control-board/README.md b/packages/control-board/README.md index bc757ff6..6fda8180 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -12,10 +12,10 @@ rewritable; they are not run records and are not evidence. | State | Plain-words meaning | |---------|----------------------| | working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. | -| waiting | The agent finished its turn with a text-only message. It is your move now. | +| waiting | The agent explicitly requested your input in its completed reply. | | error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. | | offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. | -| idle | The agent is live but has not had a conversation yet. | +| idle | The agent is live and available, including after a normal completed reply. | | unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. | Liveness means a pane in the agent's tmux session is actually running `pi` @@ -23,6 +23,79 @@ Liveness means a pane in the agent's tmux session is actually running `pi` exists but only runs bash or some other program counts as offline, not waiting. +### Explicit human attention + +A completed assistant reply requests attention only when its first nonblank text +line begins at column zero with `Input needed: ` and a nonempty request. Example: + +```text +Input needed: Choose staging or production for the approved test. +``` + +Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without +this explicit signal are idle. Code/quote examples and thinking blocks do not +count. Tool activity/errors retain precedence. A later ordinary completed reply +clears the previous request; a user/tool message is working, not waiting. + +Agents reserve the signal for Jason's decision or input, not another agent's +review or routine completion. It is display state, never action authorization. +Existing unmarked replies cannot establish a human blocker. Seen acknowledges +the current event and removes it from the attention list; it does not resolve a +genuine request or change waiting to idle. A new request reappears. + +## Relaunch activity notice + +A live native row with a positively live, matching registration gets a +`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly +newer than valid recorded `lastActivity`. Both board presentations then show +`relaunched at X, no messages since` in the current activity/preview positions. +The inspector explicitly labels retained last activity, assistant text and errors +as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp +clears the notice. Equality does not assert a relaunch. + +Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID +or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not +proof of relaunch. The comparison uses the existing session activity timestamp, +not a new transcript index or authenticated process-incarnation protocol. + +This field changes presentation only. Historical transcript files and serialized +lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen, +task selection/attribution and reply eligibility are unchanged. An old unresolved +waiting/error state therefore remains visible, with its text labelled historical, +rather than being silently cleared by the new notice. No launcher or live +registration mutation is required to test this behavior. + +## Discord connector rows + +The CLI discovers private `/discord/.json` files on every +scan, including server rescans. Only matching safe binding name and seat identity +are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB. +Discovery never resolves token paths or projects binding policy, Discord IDs or +user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors. +Library callers enable this with `discordDataRoot` on scan/startServer. + +Rows use project `fleet` and agent ` (discord: )`, distinct from +native seats. Task is fixed `Discord connector`, with source `connector`, never +inferred from the first user message: Discord routing envelopes contain private +IDs. Ordinary-agent task derivation and assistant transcript display are unchanged. +Session history comes from `sessions/discord-` without +following linked directories/files. Liveness uses the connector's readPid and +ownerState checks, not tmux or registration: only a live PID with matching boot +ID and start tick is live. Missing, invalid, dead or unverifiable owners are +non-live. The `connector` projection contains binding, alive, ownerState and +braked only; it does not expose the journal directory or owner record. + +STOP presence is shown separately as braked, even when the owner is offline. +An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents +are never read. Activity retains the usual idle/waiting/error rules. Both board +pages omit Reply for connector rows; the backend refuses connector replies before +transport even if a stale/forged native registration supplies tmux details. + +This is read-only observation, not connector control. No brake, unlock, recovery, +Discord request, counter collection or engine action is performed. Tests use +isolated bindings, sessions and process identities; they do not prove live +service transitions or authorize replacing a running board. + ## Commands ``` @@ -156,8 +229,18 @@ name alone. A registered task, project or workspace replaces the derived value and the row's source field (`taskSource`, `activeProjectSource`, `workspaceSource`) reads `registration`; the page shows a small source tag next to the value and a "Registered" line in the detail with the start time, -harness, pid and tmux session. An empty task or a null project or workspace -in the record leaves the derived value in place. Rows with no registration +harness, pid and tmux session. When the task shown is the registered one, +the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from +`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`), +or `unknown` for a record written before the field existed. Both pages show +it as a "set by NAME" tag after the task's source tag and a "Task set by" +detail row. It is what the caller claimed, not a verified identity: the +board escapes and displays it and nothing else reads it; in particular the +reply gate looks only at the registration. For every other task source +(`first-user-message`, `connector`, a stale registration, no task) it is +null, so a record never lends its setter to a task it did not set. An empty +task or a null project or workspace in the record leaves the derived value +in place. Rows with no registration are exactly as before. The board only reads `seats/`; `mosaic launch` and `mosaic seat task` are the only writers. A malformed record is listed in `registrationErrors` on the index (and on stderr for `scan`) and skipped. @@ -194,6 +277,7 @@ values), the scan refuses rather than silently dropping every mark. "cwd": "/mnt/storage/src/mosaic-stack", "task": "Read agents/darkwing/work/RESTART.md", "taskSource": "first-user-message", + "taskSetBy": null, "workspace": "/mnt/storage/src/mosaic-stack", "workspaceSource": "tmux-pane", "activeProject": "mosaic-stack", diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index 9ed6bc49..864cd94a 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -42,7 +42,7 @@ async function main() { const boardDir = join(dataRoot, "board"); const seatsDir = join(dataRoot, "seats"); if (opts.command === "serve") { - const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir }); + const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot }); const addr = server.address(); process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`); const stop = () => server.close(() => process.exit(0)); @@ -50,13 +50,15 @@ async function main() { process.on("SIGTERM", stop); return; } - const index = scan(specs, { boardDir, isAlive, seatsDir }); + const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot }); + for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`); for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`); if (opts.print) { for (const s of index.sessions) { const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " "; - const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`; - process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`); + const age = s.relaunchedAt ? "relaunch" : s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`; + const preview = s.relaunchedAt ? `relaunched at ${s.relaunchedAt}, no messages since` : s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""; + process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${preview}\n`); } } process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`); diff --git a/packages/control-board/src/discord.mjs b/packages/control-board/src/discord.mjs new file mode 100644 index 00000000..0873a923 --- /dev/null +++ b/packages/control-board/src/discord.mjs @@ -0,0 +1,73 @@ +// Read-only connector discovery. Never load binding policy or token resources. +import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs'; +import { join, resolve, parse } from 'node:path'; +import { readPid, ownerState } from '../../discord/src/journal.mjs'; + +const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; +// Check every existing ancestor, not just the final component. +export function noLinks(path) { + const absolute = resolve(path); + let current = parse(absolute).root; + for (const part of absolute.slice(current.length).split('/').filter(Boolean)) { + current = join(current, part); + try { if (lstatSync(current).isSymbolicLink()) return false; } + catch (e) { if (e.code === 'ENOENT') return true; return false; } + } + return true; +} +function stat(path) { try { return lstatSync(path); } catch { return null; } } +export function discoverDiscordAgents(dataRoot) { + const specs = [], errors = []; + const dir = join(dataRoot, 'discord'); + if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] }; + const ds = stat(dir); + if (!ds) return { specs, errors }; + if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] }; + let names; + try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; } + for (const file of names.filter(n => n.endsWith('.json'))) { + let fd; + try { + const name = file.slice(0, -5); + if (!NAME.test(name)) throw new Error(); + fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const s = fstatSync(fd); + if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error(); + const raw = JSON.parse(readFileSync(fd, 'utf8')); + if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error(); + // Do not retain the binding object, even in internal scan specs. + specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } }); + } catch { errors.push('discord discovery: skipped invalid binding'); } + finally { if (fd !== undefined) closeSync(fd); } + } + return { specs, errors }; +} +export function inspectDiscord(connector, { identity } = {}) { + const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json'); + let braked = null; + if (noLinks(dir)) { + try { lstatSync(join(dir, 'STOP')); braked = true; } + catch (e) { if (e.code === 'ENOENT') braked = false; } + } + let ownerStatus = 'invalid'; + if (noLinks(owner)) { + const s = stat(owner); + if (!s || s.isFile()) { + try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; } + } + } + return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' }; +} +// Connector session trees must not follow links, including ancestor links. +export function newestDiscordSession(dir) { + if (!noLinks(dir)) return null; + let best = null; + const walk = path => { + const s = stat(path); + if (!s || s.isSymbolicLink()) return; + if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); } + else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs }; + }; + walk(dir); + return best?.path ?? null; +} diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html index 94db3436..cf440516 100644 --- a/packages/control-board/src/page.html +++ b/packages/control-board/src/page.html @@ -63,6 +63,7 @@ .seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle} .msg-error{color:var(--danger)} .msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal} .model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap} .task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .unknown{color:var(--muted);font-style:italic} @@ -71,6 +72,7 @@ .reply-receipt{display:block;margin-top:.3em;font-size:.85em} .reply-ok{color:var(--muted)} .source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap} + .set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap} .detail-row td{background:var(--raised)} .detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)} .detail-list dt{color:var(--muted);font-family:var(--font)} @@ -194,6 +196,12 @@ function sourceLabel(source) { return SOURCE_LABEL[source] || source; } function sourceTag(source) { return source ? '' + esc(sourceLabel(source)) + "" : ""; } function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; } + // Who set a registered task (#1511): shown only when the task on the row is + // the registered one (the scan gives null otherwise). It is what the + // caller claimed when it ran `mosaic seat task`, not a verified identity, + // and it never changes what the row lets you do. + function setByTag(rec) { return rec.taskSetBy ? 'set by ' + esc(rec.taskSetBy) + "" : ""; } + function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; } // The model a seat is running, e.g. "gpt-6-astra" with the provider in the // hover title. modelText() already escapes, so the title takes it as is. // Empty when the log has not named one yet. @@ -205,6 +213,7 @@ var replyReceipts = {}; var replyDrafts = {}; function canReply(rec) { + if (rec.connector) return false; var reg = rec.registered; return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session); } @@ -216,6 +225,7 @@ } function replyControl(rec) { var id = [rec.project, rec.agent].join("/"); + if (rec.connector) return 'Board replies disabled for Discord connectors'; if (!canReply(rec)) return 'reply needs a registered seat' + replyReceipt(id); return '
' + '' + @@ -233,12 +243,14 @@ return parts.join("; "); } + function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; } function buildRowPair(rec, showProject) { var idx = rowIdx++; var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/"); var open = !!openDetails[key]; var cls = "session-row" + (rec.state === "offline" ? " is-offline" : ""); - var msg = rec.state === "error" && rec.lastError + var notice = relaunchNotice(rec); + var msg = notice ? '' + notice + '' : rec.state === "error" && rec.lastError ? '' + esc(rec.lastError) + "" : '' + esc(rec.lastAssistantText || "—") + ""; var projectCell = showProject ? "" + esc(rec.project) + "" : ""; @@ -246,7 +258,7 @@ // from the log and tmux, else "unknown". Never guessed. The source tag on // the row says which one is shown. var task = rec.task - ? '' + esc(rec.task) + "" + sourceTag(rec.taskSource) + ? '' + esc(rec.task) + "" + sourceTag(rec.taskSource) + setByTag(rec) : 'unknown'; var activeProject = rec.activeProject ? '' + esc(rec.activeProject) + "" + sourceTag(rec.activeProjectSource) @@ -255,8 +267,8 @@ '' + projectCell + '" + modelTag(rec) + "" + - "" + badge(rec.state) + seenControl(rec) + "" + - "" + esc(humanAge(rec.ageSeconds)) + "" + + "" + badge(rec.state) + (rec.connector ? ' · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '' : '') + seenControl(rec) + "" + + "" + (notice ? '' + notice + '' : esc(humanAge(rec.ageSeconds))) + "" + "" + task + "" + "" + activeProject + "" + "" + msg + "" + @@ -271,6 +283,7 @@ "
Session ID
" + esc(rec.sessionId || "—") + "
" + "
Session file
" + esc(rec.sessionFile || "—") + "
" + "
Task
" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "
" + + "
Task set by
" + setByText(rec) + "
" + "
Active project
" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "
" + "
Workspace
" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "
" + "
Model
" + modelText(rec) + "
" + @@ -278,11 +291,12 @@ "
Reply
" + replyControl(rec) + "
" + "
Session cwd
" + esc(rec.cwd || "—") + "
" + "
Tmux session
" + tmux + "
" + - "
Last activity
" + esc(rec.lastActivity || "—") + "
" + + (notice ? "
Current activity
" + notice + "
" : "") + + "
" + (notice ? "Historical last activity" : "Last activity") + "
" + esc(rec.lastActivity || "—") + "
" + "
Scanned at
" + esc(rec.scannedAt || "—") + "
" + "
Skipped lines
" + esc(rec.skippedLines) + "
" + - "
Last message
" + esc(rec.lastAssistantText || "—") + "
" + - "
Last error
" + esc(rec.lastError || "—") + "
" + + "
" + (notice ? "Historical last message" : "Last message") + "
" + esc(rec.lastAssistantText || "—") + "
" + + "
" + (notice ? "Historical last error" : "Last error") + "
" + esc(rec.lastError || "—") + "
" + ""; return main + detail; } diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index 14425263..e89c25e9 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -3,10 +3,10 @@ // // States (plain words): // working - the agent is in the middle of a turn (thinking or running tools) -// waiting - the agent finished its turn; it is your move +// waiting - the agent explicitly requests human input in its completed reply // error - the agent's last turn ended in an error, was aborted, or was cut off; look at it // offline - no tmux session for this agent, or its session no longer runs pi -// idle - the agent is live but has no conversation yet +// idle - the agent is available, with no explicit input request // unknown - liveness could not be checked (tmux missing or unresponsive); not a guess // // Board files are derived and rewritable. They are not run records. The one @@ -19,6 +19,8 @@ import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs"; +import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs'; + export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]); const TEXT_LIMIT = 240; @@ -137,12 +139,20 @@ export function hasToolCall(message) { return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall"); } -// Pure state rule. alive: true/false, or null when liveness could not be checked. -// A null check is reported as "unknown" rather than assumed alive (fail closed). -// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working, -// never waiting. The newest entry being an assistant message with a tool call, -// or a tool result, means working even if the last text looked like a question. -// waiting needs a text-only assistant message whose turn ended (stopReason stop). +// Explicit display convention, not a language classifier or control permission. +// Only a plain first nonblank text line can request human input. Quoted/code +// examples and thinking blocks must not create attention events. +function requestsHumanInput(message) { + if (!Array.isArray(message.content)) return false; + const text = message.content.filter((c) => c?.type === "text" && typeof c.text === "string") + .map((c) => c.text).join("\n"); + const first = text.split(/\r?\n/).find((line) => line.trim()) ?? ""; + const prefix = "Input needed: "; + return first.startsWith(prefix) && first.slice(prefix.length).trim().length > 0; +} + +// A missing liveness check stays unknown. Offline/errors/tool activity take +// precedence over attention text. Completion alone means idle, not waiting. export function deriveState({ alive, session }) { if (alive === false) return "offline"; if (alive !== true) return "unknown"; @@ -151,7 +161,7 @@ export function deriveState({ alive, session }) { if (m.role === "assistant") { if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error"; if (hasToolCall(m)) return "working"; - if (m.stopReason === "stop") return "waiting"; + if (m.stopReason === "stop") return requestsHumanInput(m) ? "waiting" : "idle"; return "working"; } return "working"; @@ -317,9 +327,10 @@ export function pidAlive(pid) { // it is still reported under `registered` (with alive false) but the derived // values win, because the record describes a launch that is not running. export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) { - const live = liveness(isAlive(spec.tmux)); + const connector = spec.connector ? inspectDiscord(spec.connector) : null; + const live = connector ? { alive: connector.alive, workspace: null } : liveness(isAlive(spec.tmux)); const alive = live.alive; - const file = findNewestSession(spec.sessionsDir); + const file = connector ? newestDiscordSession(spec.sessionsDir) : findNewestSession(spec.sessionsDir); const session = file ? readSession(file) : null; const state = deriveState({ alive, session }); const scannedAt = now(); @@ -328,8 +339,16 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), const needsYou = state === "waiting" || state === "error"; const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity; const cwd = session?.cwd ?? null; - const record = registration && typeof registration === "object" ? registration : null; + const record = !connector && registration && typeof registration === "object" ? registration : null; const registeredAlive = record ? isPidAlive(record.pid) : null; + // Presentation only: retain history and attention/Seen semantics. Unknown + // liveness, unmatched records and unknown timestamps cannot assert relaunch. + const launchTime = typeof record?.startedAt === "string" ? Date.parse(record.startedAt) : NaN; + const activityTime = typeof lastActivity === "string" ? Date.parse(lastActivity) : NaN; + const relaunchedAt = alive === true && registeredAlive === true && + samePath(record?.sessionsDir, spec.sessionsDir) && Number.isFinite(launchTime) && + Number.isFinite(activityTime) && launchTime > activityTime + ? new Date(launchTime).toISOString() : null; const reg = record && registeredAlive !== false ? record : null; const derivedWorkspace = live.workspace ?? cwd; const workspace = reg?.workspace ?? derivedWorkspace; @@ -339,11 +358,21 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), const activeProject = reg?.project ?? derivedProject; const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null; const firstUserText = session?.firstUserText ?? null; - const task = reg?.task ? reg.task : firstUserText; - const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null; + // Discord user text starts with a routing envelope containing private IDs. + // Connector task metadata is fixed, never inferred from that transcript. + const task = connector ? "Discord connector" : reg?.task ? reg.task : firstUserText; + const taskSource = connector ? "connector" : reg?.task ? "registration" : firstUserText ? "first-user-message" : null; + // Who set the task (#1511), only when the task shown *is* the registered + // one: a record written before the field existed reads "unknown"; a + // transcript-derived task, a stale registration and a connector's fixed + // task carry no attribution at all (null), so a record can never lend its + // setter to a task it did not set. It is the caller's claim, bounded by + // the seat package on read, and it grants nothing. + const taskSetBy = taskSource === "registration" ? (reg.taskSetBy ?? "unknown") : null; return { agent: spec.agent, project: spec.project, + ...(connector ? { connector } : {}), state, waitingOnYou: needsYou && !isSeen, seen: isSeen, @@ -359,6 +388,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), provider: session?.provider ?? null, task, taskSource, + taskSetBy, workspace, workspaceSource, activeProject, @@ -367,6 +397,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), ? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript } : null, lastActivity, + relaunchedAt, ageSeconds, lastAssistantText: session?.lastAssistantText ?? null, lastError: session?.lastError ?? null, @@ -403,12 +434,13 @@ function writeAtomic(path, data) { // Scan every spec and write /sessions//.json plus index.json. // seatsDir (optional): where `mosaic launch` registrations live; read only. -export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) { +export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive, discordDataRoot = null } = {}) { if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path"); if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null"); const seen = loadSeen(boardDir); const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir); - const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive })); + const discord = discordDataRoot ? discoverDiscordAgents(discordDataRoot) : { specs: [], errors: [] }; + const records = [...specs, ...discord.specs].map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive })); for (const rec of records) { const dir = join(boardDir, "sessions", rec.project); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -423,6 +455,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv registered: records.filter((r) => r.registered).map(seenKey), registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey), registrationErrors, + discoveryErrors: discord.errors, sessions: records, }; mkdirSync(boardDir, { recursive: true, mode: 0o700 }); diff --git a/packages/control-board/src/serve.mjs b/packages/control-board/src/serve.mjs index ab485263..4426b325 100644 --- a/packages/control-board/src/serve.mjs +++ b/packages/control-board/src/serve.mjs @@ -53,6 +53,7 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } }; const rec = index.sessions.find((r) => seenKey(r) === key); if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } }; + if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } }; const reg = rec.registered; if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } }; if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } }; @@ -130,8 +131,8 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) { } // specs: agent specs to scan on each request. boardDir: where scan writes. -export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) { - const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive }); +export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) { + const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot }); return createHttpServer((req, res) => { const url = new URL(req.url, "http://localhost"); if (req.method === "POST" && url.pathname === "/api/reply") { diff --git a/packages/control-board/tests/attention-flow.test.mjs b/packages/control-board/tests/attention-flow.test.mjs new file mode 100644 index 00000000..8d766726 --- /dev/null +++ b/packages/control-board/tests/attention-flow.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { scan, markSeen, deriveState } from '../src/scan.mjs'; + +test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => { + const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-')); + try { + const sessionsDir = join(root, 'sessions'); + mkdirSync(sessionsDir); + const file = join(sessionsDir, 's.jsonl'); + writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n'); + const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }]; + const boardDir = join(root, 'board'); + const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0]; + const append = (second, text) => appendFileSync(file, JSON.stringify({ + type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`, + message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] }, + }) + '\n'); + append(1, 'Input needed: Choose A or B.'); + let r = get(); + assert.equal(r.state, 'waiting'); + assert.equal(r.waitingOnYou, true); + markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true }); + r = get(); + assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request'); + assert.equal(r.seen, true); + assert.equal(r.waitingOnYou, false); + append(2, 'BOARD_REPLY_OK'); + r = get(); + assert.equal(r.state, 'idle'); + assert.equal(r.waitingOnYou, false); + assert.equal(r.seen, false, 'ordinary completion needs no Seen action'); + append(3, 'Input needed: Confirm the revised choice.'); + r = get(); + assert.equal(r.state, 'waiting'); + assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => { + const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } }); + const text = (value) => [{ type: 'text', text: value }]; + for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) { + assert.equal(derive(text(value)), 'idle', value); + } + assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle'); + assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting'); + assert.equal(derive(text('Input needed: Choose A.'), false), 'offline'); + assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown'); +}); diff --git a/packages/control-board/tests/attention.test.mjs b/packages/control-board/tests/attention.test.mjs new file mode 100644 index 00000000..d27b6eec --- /dev/null +++ b/packages/control-board/tests/attention.test.mjs @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deriveState } from '../src/scan.mjs'; + +const state = (text, overrides = {}) => deriveState({ alive: true, session: { + lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides }, +} }); + +test('completed smoke replies and ordinary questions are idle, not human blockers', () => { + for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) { + assert.equal(state(text), 'idle', text); + } +}); + +test('only an explicit first-line input request makes a finished reply waiting', () => { + assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting'); + for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) { + assert.equal(state(text), 'idle', text); + } +}); + +test('tool activity, user text, errors and unfinished turns override attention text', () => { + const text = 'Input needed: Choose A.'; + assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working'); + assert.equal(state(text, { role: 'user' }), 'working'); + assert.equal(state(text, { role: 'toolResult' }), 'working'); + assert.equal(state(text, { stopReason: 'error' }), 'error'); + assert.equal(state(text, { stopReason: undefined }), 'working'); +}); diff --git a/packages/control-board/tests/discord.test.mjs b/packages/control-board/tests/discord.test.mjs new file mode 100644 index 00000000..9b69d31b --- /dev/null +++ b/packages/control-board/tests/discord.test.mjs @@ -0,0 +1,145 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs'; +import { scan, scanAgent } from '../src/scan.mjs'; +import { replyToRow, startServer } from '../src/serve.mjs'; +import { identityOf } from '../../discord/src/journal.mjs'; +import { envelope } from '../../discord/src/context.mjs'; +import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; + +test('STOP access failure is unknown, not absence, under a non-root identity', () => { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)}; + assert.notEqual(process.getuid(), 0, 'permission regression must not run as root'); + const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-')); + const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir }; + mkdirSync(dir); + try { + assert.equal(inspectDiscord(connector).braked, false, 'verified absence'); + writeFileSync(join(dir, 'STOP'), 'private contents never read'); + assert.equal(inspectDiscord(connector).braked, true, 'verified presence'); + chmodSync(dir, 0o000); + const inaccessible = inspectDiscord(connector); + assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown'); + assert.equal(inaccessible.alive, false); + chmodSync(dir, 0o700); + assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP'); + rmSync(join(dir, 'STOP')); + assert.equal(inspectDiscord(connector).braked, false, 'verified removal'); + } finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); } + `], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) }); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); + +test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => { + const f = fixture(); + try { + const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' }); + const sessionsDir = join(f.root, 'sessions', 'discord-demo'); + writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n'); + const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]); + assert.equal(connector.task, 'Discord connector'); + assert.equal(connector.taskSource, 'connector'); + for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false); + const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true }); + assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim()); + assert.equal(ordinary.taskSource, 'first-user-message'); + } finally { f.cleanup(); } +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'board-discord-')); + mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true }); + writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 }); + const sessions = join(root, 'sessions', 'discord-demo'); mkdirSync(sessions, { recursive: true }); + writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'done' }] } }) + '\n'); + const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json'); + writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) })); + return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => { + const f = fixture(); + try { + const result = discoverDiscordAgents(f.root); + assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []); + assert.equal(result.specs[0].agent, 'researcher (discord: demo)'); + assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false); + writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 }); + symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json')); + writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 }); + chmodSync(join(f.root, 'discord', 'demo.json'), 0o644); + const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4); + assert.equal(JSON.stringify(bad).includes('PRIVATE'), false); + } finally { f.cleanup(); } +}); + +test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => { + const f = fixture(); + try { + const spec = discoverDiscordAgents(f.root).specs[0]; + const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } }); + assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null); + // #1511: the connector's fixed task borrows no attribution from a native registration. + assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null); + assert.equal(JSON.stringify(row()).includes('forged-setter'), false); + writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT'); + assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle'); + assert.equal(JSON.stringify(row()).includes('PRIVATE'), false); + assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false); + assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown'); + assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch'); + for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) { + writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline'); + } + rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent'); + symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false); + } finally { f.cleanup(); } +}); + +test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => { + let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; }; + const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } }; + assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409); + delete r.connector; + assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409); + assert.equal(calls, 0); + r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1); +}); + +test('server rescans connector discovery and refuses HTTP reply without transport', async () => { + const f = fixture(); let server; + try { + const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats'); + writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid })); + server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } }); + const base = `http://127.0.0.1:${server.address().port}`; + let response = await fetch(base + '/api/board'); const board = await response.json(); + assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true); + assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged'); + response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) }); + assert.equal(response.status, 409); + rmSync(join(f.root, 'discord', 'demo.json')); + assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0); + } finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); } +}); + +test('connector session links and linked directories are not read', () => { + const f = fixture(); + try { + const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true }); + symlinkSync(join(f.root, 'discord'), sessions); + const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root }); + assert.equal(result.sessions[0].sessionFile, null); + rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord')); + assert.equal(discoverDiscordAgents(f.root).specs.length, 0); + } finally { f.cleanup(); } +}); diff --git a/packages/control-board/tests/relaunch.test.mjs b/packages/control-board/tests/relaunch.test.mjs new file mode 100644 index 00000000..010abf45 --- /dev/null +++ b/packages/control-board/tests/relaunch.test.mjs @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { scanAgent } from '../src/scan.mjs'; +import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; +import { identityOf } from '../../discord/src/journal.mjs'; + +const oldAt = '2026-09-14T10:00:00.000Z'; +const launchAt = '2026-09-15T10:00:00.000Z'; +const newAt = '2026-09-15T10:01:00.000Z'; +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'board-relaunch-')); + const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir); + const file = join(sessionsDir, 'session.jsonl'); + const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n'; + writeFileSync(file, message(oldAt, 'Input needed: Historical request.')); + const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }; + const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' }; + const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra }); + return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) }; +} + +test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => { + const f = fixture(); + try { + const original = readFileSync(f.file, 'utf8'); + const row = f.read(); + assert.equal(row.relaunchedAt, launchAt); + assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.'); + assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true); + assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration'); + assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true); + assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false); + assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages'); + appendFileSync(f.file, f.message(newAt, 'Fresh completion.')); + const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle'); + assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt); + } finally { f.close(); } +}); + +test('CLI print uses the relaunch notice instead of old current preview', () => { + const f = fixture(); + try { + const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true }); + const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir); + const dataRoot = join(f.root, 'data'); + writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir }); + const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot })); + const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 }); + assert.equal(run.status, 0, run.stderr); + assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`)); + assert.equal(run.stdout.includes('Historical request.'), false); + } finally { f.close(); } +}); + +test('connector owner and fixed task never inherit a native relaunch notice', () => { + const f = fixture(); + try { + const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true }); + writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) })); + const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } }); + assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector'); + assert.equal(row.taskSetBy, null); assert.equal(row.registered, null); + } finally { f.close(); } +}); + +test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => { + const f = fixture(); + try { + assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null); + for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null); + for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null); + assert.equal(f.read({ registration: null }).relaunchedAt, null); + assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null); + assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null); + writeFileSync(f.file, f.message('invalid', 'Undated history.')); + assert.equal(f.read().relaunchedAt, null); + writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n'); + assert.equal(f.read().relaunchedAt, null); + rmSync(f.file); assert.equal(f.read().relaunchedAt, null); + } finally { f.close(); } +}); diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs index 7f451b61..708ae52d 100644 --- a/packages/control-board/tests/scan.test.mjs +++ b/packages/control-board/tests/scan.test.mjs @@ -284,13 +284,13 @@ test("deriveState: full state table", () => { { name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" }, { name: "no session is idle", input: { alive: true, session: null }, expected: "idle" }, { name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" }, - { name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" }, + { name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" }, { name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" }, { name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" }, { name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" }, { name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" }, { name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" }, - { name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" }, + { name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" }, { name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" }, { name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" }, { name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" }, @@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor assert.equal(rec.waitingOnYou, false); }); -test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => { +test("rule: a finished ordinary turn is idle, even if it says your move", () => { const root = makeRoot(); writeSessionFile(join(root, "sessions"), "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }), @@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }), ]); const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") }); - assert.equal(rec.state, "waiting"); - assert.equal(rec.waitingOnYou, true); + assert.equal(rec.state, "idle"); + assert.equal(rec.waitingOnYou, false); }); // --------------------------------------------------------------------------- @@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", () mkdirSync(errorDir, { recursive: true }); writeSessionFile(waitingDir, "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ]); writeSessionFile(workingDir, "s.jsonl", [ sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }), @@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover sessionPath, [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ].join("\n") + "\n" ); @@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes join(sessionsDir, "s.jsonl"), [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ].join("\n") + "\n" ); @@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears mkdirSync(sessionsDir, { recursive: true }); writeSessionFile(sessionsDir, "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ]); const seen = { "p/a": "2026-09-01T00:00:01Z" }; const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); @@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait mkdirSync(sessionsDir, { recursive: true }); writeSessionFile(sessionsDir, "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ]); const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen }); @@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr const lastActivity = "2026-09-01T00:00:01Z"; writeSessionFile(sessionsDir, "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ]); mkdirSync(boardDir, { recursive: true }); const seenPath = join(boardDir, "seen.json"); @@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", () writeFile(join(boardDir, "seen.json"), "{ bad json"); assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError); }); + +// --------------------------------------------------------------------------- +// Task attribution (#1511): taskSetBy on the row, tied to the task source. +// --------------------------------------------------------------------------- + +function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) { + const sessionsDir = join(root, "sessions"); + writeSessionFile(sessionsDir, "s.jsonl", [ + sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }), + messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }), + ]); + const spec = { agent: "a", project: "p", sessionsDir, tmux: {} }; + const base = makeRegistration({ + resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" }, + task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"), + }); + const registration = taskSetBy === undefined ? base : { ...base, taskSetBy }; + return { spec, sessionsDir, registration }; +} + +test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => { + const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" }); + const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration }); + assert.equal(rec.taskSource, "registration"); + assert.equal(rec.taskSetBy, "darkwing"); + assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only"); + + const old = attributionFixture(makeRoot()); + assert.equal("taskSetBy" in old.registration, false); + const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration }); + assert.equal(oldRec.taskSource, "registration"); + assert.equal(oldRec.taskSetBy, "unknown"); + + const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" }); + assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown"); +}); + +test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => { + const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" }); + const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW }); + assert.equal(withoutReg.taskSource, "first-user-message"); + assert.equal(withoutReg.taskSetBy, null); + assert.ok("taskSetBy" in withoutReg); + + const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" }); + const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration }); + assert.equal(emptyRec.task, "derived task"); + assert.equal(emptyRec.taskSource, "first-user-message"); + assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task"); + assert.notEqual(emptyRec.registered, null); + + const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" }); + const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false }); + assert.equal(staleRec.task, "derived task"); + assert.equal(staleRec.taskSource, "first-user-message"); + assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter"); + assert.equal(staleRec.registered.alive, false); + + const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW }); + assert.equal(noSession.task, null); + assert.equal(noSession.taskSetBy, null); +}); + +test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => { + const root = makeRoot(); + const seatsDir = join(root, "seats"); + const boardDir = join(root, "board"); + const f = attributionFixture(root, { taskSetBy: "jason@host:1" }); + writeRegistration(seatsDir, f.registration); + const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW }); + assert.equal(index.sessions[0].taskSetBy, "jason@host:1"); + assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1"); + assert.deepEqual(index.registrationErrors, []); + + // A hand-edited record with markup in the field does not reach the board. + writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "" })); + const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW }); + assert.equal(rejected.sessions[0].taskSource, "first-user-message"); + assert.equal(rejected.sessions[0].taskSetBy, null); + assert.equal(rejected.sessions[0].registered, null); + assert.equal(rejected.registrationErrors.length, 1); + assert.match(rejected.registrationErrors[0], /taskSetBy/); + assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index"); +}); diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 0d7418a6..174099dd 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async () assert.equal(first.headers.get("content-type"), "application/json"); assert.equal(first.headers.get("cache-control"), "no-store"); const firstBody = await first.json(); - assert.equal(firstBody.sessions[0].state, "waiting"); - assert.ok(firstBody.waitingOnYou.includes("proj/agent1")); + assert.equal(firstBody.sessions[0].state, "idle"); + assert.ok(!firstBody.waitingOnYou.includes("proj/agent1")); assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan"); assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan"); @@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false const sessionsDir = join(root, "sessions"); writeSessionFile(sessionsDir, "s.jsonl", [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ]); const boardDir = join(root, "board"); const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }]; @@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with join(sessionsDir, "s.jsonl"), [ sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), - messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }), + messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }), ].join("\n") + "\n" ); const boardDir = join(dataRoot, "board"); @@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box"); assert.doesNotMatch(html, /send-keys/, "the page never talks tmux"); }); + +// --------------------------------------------------------------------------- +// Task attribution (#1511): serialized by the server, rendered by page.html. +// --------------------------------------------------------------------------- + +test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => { + const root = makeRoot(); + const sessionsDir = join(root, "sessions"); + const derivedDir = join(root, "derived-sessions"); + for (const dir of [sessionsDir, derivedDir]) { + writeSessionFile(dir, "s.jsonl", [ + sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }), + messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }), + ]); + } + const seatsDir = join(root, "seats"); + const reg = makeRegistration({ + resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null }, + task: "registered task", + }); + writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" }); + const specs = [ + { agent: "agent1", project: "proj", sessionsDir, tmux: {} }, + { agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} }, + ]; + const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "" }); + try { + const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json(); + const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r])); + assert.equal(byAgent.agent1.taskSource, "registration"); + assert.equal(byAgent.agent1.taskSetBy, "darkwing"); + assert.equal(byAgent.agent2.taskSource, "first-user-message"); + assert.equal(byAgent.agent2.taskSetBy, null); + assert.deepEqual(body.registrationErrors, []); + } finally { + await new Promise((r) => server.close(r)); + } +}); + +test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => { + const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8"); + const lines = html.split("\n"); + const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/); + assert.ok(rowPair); + assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row"); + assert.match(rowPair[0], /
Task set by<\/dt>
" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row"); + const tagLine = lines.find((l) => l.includes("function setByTag(rec)")); + assert.ok(tagLine); + assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value"); + assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped"); + assert.match(tagLine, /not verified/); + const textLine = lines.find((l) => l.includes("function setByText(rec)")); + assert.ok(textLine); + assert.match(textLine, /esc\(rec\.taskSetBy\)/); + assert.match(textLine, /not verified/); + // Attribution never enables a reply: canReply() reads the registration only. + const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/); + assert.ok(canReplyFn); + assert.equal(canReplyFn[0].includes("taskSetBy"), false); + assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false); +}); diff --git a/packages/seat/README.md b/packages/seat/README.md index b16f4753..f0701708 100644 --- a/packages/seat/README.md +++ b/packages/seat/README.md @@ -3,7 +3,7 @@ `mosaic launch ` starts a seat through its existing launch script, unchanged, and leaves one registration record that the control board reads instead of guessing. `mosaic seat task ` changes the task on -that record. Nothing else. No provider or auth registry, no roster schema +that record and notes who set it. Nothing else. No provider or auth registry, no roster schema change, no stopping or killing of seats, one record per seat. Issue #1504. Plain ESM, no dependencies, Node 24 or newer. @@ -17,7 +17,7 @@ or `seat` command; run it by path. ``` scripts/mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...] -scripts/mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +scripts/mosaic seat task [--by NAME] [--layout repo|fleet|unknown] [--config PATH] ``` - `` is a name under `/agents/` (`--repo` defaults to the @@ -36,6 +36,17 @@ scripts/mosaic seat task [--layout repo|fleet|unknown] [--config P - The launch script receives `MOSAIC_LAUNCH_REGISTERED=`. A launch script that sees this variable is already registered and must not call `mosaic launch` again; `mosaic launch` refuses to run when it is set. +- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME` + if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word + `unknown`. An explicit value must be one token of 1 to 64 characters + (letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit); + anything else refuses with exit 4 before the record is touched. A set but + malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as + `unknown`; `--by` is the explicit way past it. The value is never echoed + back in a refusal. **This is what the caller said, not a verified + identity.** Nothing checks it, nothing grants on it; the board displays + it next to the task and does no more with it. `launch --task` does not + set the field. The four repository seats (`agents/darkwing`, `agents/dewey`, `agents/filbert`, `agents/rocko`) register themselves: their `launch.sh` @@ -81,6 +92,10 @@ scan never changes it. } ``` +After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the +same record also carries `"taskSetBy": "jason"` and a non-null +`updatedAt`; `startedAt` and every other field are unchanged. + Fields asked for in the brief: `seat`, `project`, `task` (empty unless `--task` or a later `seat task`), `workspace`, `tmux` (session name and socket, from the `TMUX` variable of the pane the launch ran in; null outside @@ -100,6 +115,11 @@ Fields added, and why: the record is still written but the board cannot match it). - `updatedAt`: set only by `seat task`, so a task change is distinguishable from a relaunch. +- `taskSetBy`: set only by `seat task` (see above). Optional in the shape: + a record written before the field existed loads unchanged, the board shows + `unknown` for it, and nothing rewrites it until the next `seat task` or + launch. The record version stays 1; a value that is not one bounded token + makes the record unreadable (refused, not misread). - `version`: so a later shape change can be refused rather than misread. `project` and `workspace` are derived only for the repo layout (the @@ -119,7 +139,11 @@ registration are unchanged. A malformed record is reported in `registrationErrors` on the index and skipped; it never takes the board down and it is never treated as absent silently. A record whose pid is no longer running is stale: still shown on the Registered line, but it does -not override anything. +not override anything. The row's `taskSetBy` is the record's setter only +while the task shown is the registered one (`taskSource` `registration`); +otherwise it is null, so a stale or empty registration never attributes a +transcript-derived task, and a Discord connector's fixed task never borrows +a native record's setter. The repository launch scripts are the only launchers that register. Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling, diff --git a/packages/seat/src/cli.mjs b/packages/seat/src/cli.mjs index 1e9b48e2..35f876a1 100755 --- a/packages/seat/src/cli.mjs +++ b/packages/seat/src/cli.mjs @@ -2,7 +2,11 @@ // Usage: // mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] // [--harness NAME] [--repo PATH] [--config PATH] [-- args...] -// mosaic seat task [--layout repo|fleet|unknown] [--config PATH] +// mosaic seat task [--by NAME] [--layout repo|fleet|unknown] [--config PATH] +// +// `seat task` records who set the task as taskSetBy: `--by NAME`, else +// $MOSAIC_AGENT_NAME, else "unknown". This is what the caller claimed; it is +// not verified and it authorizes nothing. // // `launch` writes /seats///registration.json, then replaces // itself with the seat's launch.sh, unchanged, with everything after `--` as @@ -15,12 +19,12 @@ import { rmSync } from "node:fs"; import { SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext, - makeRegistration, writeRegistration, updateTask, + makeRegistration, writeRegistration, updateTask, resolveSetBy, } from "./seat.mjs"; const USAGE = [ "usage: mosaic launch [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]", - " mosaic seat task [--layout repo|fleet|unknown] [--config PATH]", + " mosaic seat task [--by NAME] [--layout repo|fleet|unknown] [--config PATH]", ].join("\n"); export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED"; @@ -49,10 +53,10 @@ function parseLaunch(argv) { } function parseSeatTask(argv) { - const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() }; + const opts = { seat: null, task: null, layout: null, by: null, config: defaultConfigPath() }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--config" || a === "--layout") { + if (a === "--config" || a === "--layout" || a === "--by") { if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4); opts[a.slice(2)] = argv[++i]; } else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4); @@ -90,9 +94,12 @@ function launch(argv) { function seatTask(argv) { const opts = parseSeatTask(argv); + // Attribution is resolved (and an invalid value refused) before any read + // or write, so a refusal leaves the record exactly as it was. + const setBy = resolveSetBy({ by: opts.by, env: process.env }); const seats = seatsDir(loadDataRoot(opts.config)); - const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout }); - process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`); + const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout, setBy }); + process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters, set by ${setBy})\n`); return 0; } diff --git a/packages/seat/src/seat.mjs b/packages/seat/src/seat.mjs index ec0d0a96..9a8f7647 100644 --- a/packages/seat/src/seat.mjs +++ b/packages/seat/src/seat.mjs @@ -23,6 +23,15 @@ import { spawnSync } from "node:child_process"; export const REGISTRATION_VERSION = 1; export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; export const TASK_LIMIT = 2000; +// Who set the task (#1511): caller-supplied attribution, bounded to one +// short token so it cannot carry markup or whitespace. The bound is a syntax +// limit, not a privacy filter: a numeric ID or an email-like name still fits, +// so callers choose what they put here. It is what the caller claimed, not +// an authenticated identity, and it grants nothing: the board displays it +// and does no more with it. +export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/; +export const SET_BY_UNKNOWN = "unknown"; +export const SET_BY_ENV = "MOSAIC_AGENT_NAME"; // exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or // configuration, 4 usage. @@ -121,6 +130,7 @@ export function tmuxContext({ env = process.env, exec = spawnSync } = {}) { const FIELDS = Object.freeze([ "version", "seat", "project", "task", "workspace", "tmux", "harness", "startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt", + "taskSetBy", ]); const isNullableString = (v) => v === null || typeof v === "string"; @@ -134,6 +144,9 @@ export function validateRegistration(record) { if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version"); if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid"); if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string"); + // Optional: records written before #1511 have no taskSetBy and still load + // (the board shows "unknown"); no version change, no migration on read. + if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid"); for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) { if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`); } @@ -208,17 +221,37 @@ export function findRegistrations(seats, seat) { return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean); } -// Change the task field only. Refuses when the seat was never launched -// through `mosaic launch`, because there is nothing to attach the task to. -// A name that exists in more than one layout must be qualified with layout. -export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) { +// Who a `seat task` is attributed to: an explicit `--by NAME`, else the +// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match +// SET_BY_NAME; so must a non-empty environment value, because a malformed +// or unexpected value there is not the same as no value and is refused +// rather than silently reported as "unknown". Neither value is echoed back. +// The result is a claim by the caller, nothing more. +export function resolveSetBy({ by = null, env = process.env } = {}) { + if (by !== null && by !== undefined) { + if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4); + return by; + } + const fromEnv = env[SET_BY_ENV]; + if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN; + if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4); + return fromEnv; +} + +// Change the task field, and record who set it (taskSetBy), and updatedAt. +// Every other field, startedAt included, is carried over unchanged. Refuses +// when the seat was never launched through `mosaic launch`, because there is +// nothing to attach the task to. A name that exists in more than one layout +// must be qualified with layout. +export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) { if (typeof task !== "string") throw new SeatError("task must be a string", 4); if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4); + if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4); const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat); if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1); if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4); const record = found[0]; - const updated = { ...record, task, updatedAt: now().toISOString() }; + const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() }; writeRegistration(seats, updated); return updated; } diff --git a/packages/seat/tests/seat.test.mjs b/packages/seat/tests/seat.test.mjs index 6b9fa14f..bd0f6e04 100644 --- a/packages/seat/tests/seat.test.mjs +++ b/packages/seat/tests/seat.test.mjs @@ -29,7 +29,10 @@ import { updateTask, findRegistrations, samePath, + resolveSetBy, TASK_LIMIT, + SET_BY_NAME, + SET_BY_UNKNOWN, } from "../src/seat.mjs"; const pkgRoot = resolve(import.meta.dirname, ".."); @@ -91,6 +94,9 @@ function buildConfig(root, dataRoot = join(root, "data")) { function cliEnv(overrides = {}) { const env = { ...process.env, TMUX: "", TMUX_PANE: "" }; delete env.MOSAIC_LAUNCH_REGISTERED; + // The developer's shell may carry a seat name (agents/rocko/launch.sh + // exports one); attribution tests set it explicitly or not at all. + delete env.MOSAIC_AGENT_NAME; return { ...env, ...overrides }; } @@ -290,7 +296,7 @@ test("writeRegistration/readRegistration: round trip, permissions, absence, and // 7. updateTask // --------------------------------------------------------------------------- -test("updateTask: changes task and updatedAt only, and refuses appropriately", () => { +test("updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately", () => { const root = makeRoot(); const seatDir = buildRepoSeat(root, "myseat"); const resolved = resolveSeat("myseat", { repo: root }); @@ -300,11 +306,14 @@ test("updateTask: changes task and updatedAt only, and refuses appropriately", ( const updated = updateTask(seats, "myseat", "revised"); assert.equal(updated.task, "revised"); + assert.equal(updated.taskSetBy, SET_BY_UNKNOWN, "no setBy option records the literal unknown"); assert.notEqual(updated.updatedAt, null); for (const key of Object.keys(record)) { if (key === "task" || key === "updatedAt") continue; assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`); } + assert.equal(updated.startedAt, record.startedAt, "startedAt is the launch time and survives a task update"); + assert.deepEqual(Object.keys(updated).sort(), [...Object.keys(record), "taskSetBy"].sort(), "exactly one field is added"); // Refuses when there is no registration to attach to. assert.throws(() => updateTask(seats, "ghost", "x"), (err) => { @@ -560,3 +569,165 @@ test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", () assert.equal(samePath(1, "x"), false); assert.equal(samePath(null, target), false); }); + +// --------------------------------------------------------------------------- +// 14. Task attribution (#1511): taskSetBy on the record, resolveSetBy, and the +// --by / MOSAIC_AGENT_NAME precedence at the CLI. Attribution is a claim by +// the caller: bounded, escaped by the presentations, never authority. +// --------------------------------------------------------------------------- + +test("resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4", () => { + assert.equal(resolveSetBy({ by: "darkwing", env: { MOSAIC_AGENT_NAME: "rocko" } }), "darkwing"); + assert.equal(resolveSetBy({ by: null, env: { MOSAIC_AGENT_NAME: "rocko" } }), "rocko"); + assert.equal(resolveSetBy({ env: {} }), SET_BY_UNKNOWN); + assert.equal(resolveSetBy({ env: { MOSAIC_AGENT_NAME: "" } }), SET_BY_UNKNOWN); + assert.equal(resolveSetBy({ by: "jason@host:1", env: {} }), "jason@host:1"); + assert.equal(resolveSetBy({ by: "a".repeat(64), env: {} }), "a".repeat(64), "64 characters is the bound"); + const refused = (fn, pattern) => assert.throws(fn, (err) => { + assert.ok(err instanceof SeatError); + assert.equal(err.exitCode, 4); + assert.match(err.message, pattern); + return true; + }); + for (const bad of ["", " ", "a".repeat(65), "x", "two words", "-leading", ".leading", "tab\tname", "new\nline", "ünïcode", "a/b", "a;b", "$(x)"]) { + refused(() => resolveSetBy({ by: bad, env: { MOSAIC_AGENT_NAME: "valid" } }), /--by/); + assert.equal(SET_BY_NAME.test(bad), false); + // The offending value is never echoed back. + if (bad.trim().length > 1) assert.throws(() => resolveSetBy({ by: bad, env: {} }), (err) => !err.message.includes(bad)); + } + refused(() => resolveSetBy({ by: 42, env: {} }), /--by/); + // A set but malformed environment value is refused, not read as unknown: + // silently reporting "unknown" would hide a misconfigured seat, and --by + // is the explicit way past it. + refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: ""], { MOSAIC_AGENT_NAME: "rocko" }); + assert.equal(r.status, 4); + assert.match(r.stderr, /refused: --by/); + assert.equal(r.stderr.includes(" completed' }] } }) + '\n'); + // #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing. + const seatsDir = join(root, 'seats'); + writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' }); + board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } }); + web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); + const base = `http://127.0.0.1:${web.address().port}`; + const payload = await (await fetch(base + '/api/board')).json(); + assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle'); + assert.equal(JSON.stringify(payload).includes('PRIVATE'), false); + assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null); + assert.equal(JSON.stringify(payload).includes('forged'), false); + const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) }); + assert.equal(reply.status, 409); + b = await browser(); await b.viewport(320, 1000); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`); + await b.navigate(`http://127.0.0.1:${board.address().port}`); + await wait('document.querySelector(".session-row")'); + assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/); + assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/); + assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false); + assert.equal(await b.evaluate('!!window.injected'), false); + await b.navigate(base); + await wait('document.querySelector("table.sessions [data-open]")'); + await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); + assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /braked \(STOP\); owner live/); + assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Board replies disabled for Discord connectors/); + assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false); + assert.equal(await b.evaluate('!!window.injected'), false); + // R1-U1: the connector's fixed task is "not applicable", never "unknown", and never the forged native setter. + assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/); + assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/); + assert.equal(await b.evaluate('document.body.textContent.includes("forged")'), false); + assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true); + rmSync(join(dir, 'STOP')); rmSync(join(dir, 'run.lock', 'owner.json')); + await b.evaluate('document.querySelector("#hide-offline").click();document.querySelector("#refresh").click()'); + await wait('document.querySelector("#inspection").textContent.includes("owner absent")'); + assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/); + assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/); + } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/webui/tests/fixture.mjs b/packages/webui/tests/fixture.mjs index 8272163d..1a3718ef 100644 --- a/packages/webui/tests/fixture.mjs +++ b/packages/webui/tests/fixture.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startServer as startBoard } from '../../control-board/src/serve.mjs'; -import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; +import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs'; import { startServer } from '../src/serve.mjs'; export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); }); export async function fixture() { @@ -14,7 +14,7 @@ export async function fixture() { const captures = []; for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) { const sessionsDir = join(root, agent); mkdirSync(sessionsDir); - const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? ' }] }; + const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: (state === 'waiting' ? 'Input needed: ' : '') + 'done? ' }] }; const lines = [ { type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' }, { type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } }, @@ -23,7 +23,11 @@ export async function fixture() { if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } }); writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n'); specs.push({ agent, project, sessionsDir, tmux: { session: agent } }); - if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid })); + if (agent === 'agent1') { + writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid })); + // #1511: the same path `mosaic seat task --by` takes, on the fixture record only. + updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' }); + } } const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true, exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal ' : '' }; }, diff --git a/packages/webui/tests/relaunch.test.mjs b/packages/webui/tests/relaunch.test.mjs new file mode 100644 index 00000000..08275514 --- /dev/null +++ b/packages/webui/tests/relaunch.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startServer as startBoard } from '../../control-board/src/serve.mjs'; +import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; +import { startServer } from '../src/serve.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z'; +const notice = `relaunched at ${launchAt}, no messages since`; + +test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-')); + let board, web, b; + try { + const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir); + const file = join(sessionsDir, 's.jsonl'); + const line = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n'; + const history = line(oldAt, 'Input needed: OLD_FIXTURE_REPLY '); + writeFileSync(file, history); + const seatsDir = join(root, 'seats'); + writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' }); + board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } }); + const boardURL = `http://127.0.0.1:${board.address().port}`; + web = await startServer({ port: 0, board: boardURL }); + const webURL = `http://127.0.0.1:${web.address().port}`; + const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0]; + assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt); + assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter'); + b = await browser(); await b.viewport(320, 1000); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`); + await b.navigate(boardURL); await wait('document.querySelector(".session-row")'); + const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent'); + assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false); + await b.evaluate('document.querySelector(".row-toggle").click()'); + assert.match(await b.evaluate('document.querySelector(".detail-row").textContent'), /Historical last message.*OLD_FIXTURE_REPLY/s); + assert.equal(await b.evaluate('!!document.querySelector("img")'), false); + assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true); + await b.navigate(webURL); await wait('document.querySelector("table.sessions [data-open]")'); + assert.ok((await b.evaluate('document.querySelector("table.sessions").textContent')).includes(notice)); + const card = await b.evaluate('document.querySelector("#waiting").textContent'); + assert.ok(card.includes(notice)); assert.equal(card.includes('OLD_FIXTURE_REPLY'), false); + await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); + const inspector = await b.evaluate('document.querySelector("#inspection").textContent'); + assert.ok(inspector.includes(notice)); assert.match(inspector, /Historical last assistant text.*OLD_FIXTURE_REPLY/s); + assert.match(inspector, /Historical last activity/); assert.match(inspector, /fixture-setter/); + assert.equal(await b.evaluate('!!document.querySelector("#reply")'), true, 'reply eligibility unchanged'); + assert.equal(await b.evaluate('!!document.querySelector("img")'), false); + assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true); + appendFileSync(file, line(newAt, 'NEW_FIXTURE_REPLY')); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")'); + assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false); + assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false); + await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")'); + assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false); + } finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/test-darkwing-launch.mjs b/scripts/test-darkwing-launch.mjs index 7196d50f..a002e847 100644 --- a/scripts/test-darkwing-launch.mjs +++ b/scripts/test-darkwing-launch.mjs @@ -11,7 +11,7 @@ const source = resolve(import.meta.dirname, '..'); // forgets inherits these and fails instead of registering into a real data root. process.env.MOSAIC_CONFIG = join(tmpdir(), 'mosaic-launch-test-no-config.json'); process.env.MOSAIC_LAUNCH_REGISTERED = ''; -for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch context, resources, session recovery, and refusals`, () => { +for (const agent of ['darkwing', 'dewey', 'filbert', 'researcher']) test(`${agent} launch context, resources, session recovery, and refusals`, () => { const root = mkdtempSync(join(tmpdir(), `${agent} launch `)); try { for (const dir of ['agents', 'scripts', 'contracts', 'user', 'node_modules/.bin', 'extensions/goal']) {