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 <[email protected]>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"candidate": "R2",
|
||||
"syntheticOnly": true,
|
||||
"taskContainsEnvelopeAuthorId": true,
|
||||
"taskContainsEnvelopeMessageId": true,
|
||||
"taskSource": "first-user-message"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"discordDependencyCommit": "1ac812d3d5f3939221e7578502a10240ac82a6af",
|
||||
"verifiedTrackedDependencyFiles": 31,
|
||||
"sevenWorkingAndFrozenPinsUnchanged": true,
|
||||
"scope": "byte comparison, not proof of snapshot construction history"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
{"number": 1512, "creator": "darkwing", "charter": "docs/plans/2026-09-15_relaunch-activity.md"}
|
||||
@@ -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/[email protected]
|
||||
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
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"at": "2026-09-14T14:08:03.786Z",
|
||||
"issue": 1511,
|
||||
"exit": 4,
|
||||
"explicitByRejected": true,
|
||||
"liveFilesChanged": false
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
--- a/docs/TOOLS.md
|
||||
+++ b/docs/TOOLS.md
|
||||
@@ -137 +137 @@
|
||||
-scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
+scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Executable
+8
@@ -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 "$@"
|
||||
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -134,7 +134,7 @@ Push is never automatic.
|
||||
|
||||
```bash
|
||||
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
```
|
||||
|
||||
Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 <dataRoot>/discord/<binding>.json, with 0600 regular
|
||||
non-symlink files. Project fleet, agent `<seat> (discord: <binding>)`.
|
||||
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 <dataRoot>/sessions/discord-<binding>.
|
||||
- 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 `<dataRoot>/discord/<binding>.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 `<seat> (discord: <binding>)`, 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-<binding>` 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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||
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 ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
||||
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 '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
||||
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
||||
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
||||
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
||||
@@ -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 ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
@@ -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
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
||||
: '<span class="unknown">unknown</span>';
|
||||
var activeProject = rec.activeProject
|
||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||
@@ -255,8 +267,8 @@
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
||||
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
||||
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
||||
"<td>" + task + "</td>" +
|
||||
"<td>" + activeProject + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
@@ -271,6 +283,7 @@
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
||||
@@ -278,11 +291,12 @@
|
||||
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
||||
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
@@ -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 <boardDir>/sessions/<project>/<agent>.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 });
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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: "<img src=x onerror=alert(1)>" }));
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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: "<html></html>" });
|
||||
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], /<dt>Task set by<\/dt><dd>" \+ 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);
|
||||
});
|
||||
|
||||
+27
-3
@@ -3,7 +3,7 @@
|
||||
`mosaic launch <seat>` 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 <seat> <text>` 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 <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||||
```
|
||||
|
||||
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
|
||||
@@ -36,6 +36,17 @@ scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config P
|
||||
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. 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,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// Usage:
|
||||
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||||
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||||
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
|
||||
// mosaic seat task <seat> <text> [--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 <dataRoot>/seats/<layout>/<seat>/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 <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
|
||||
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
|
||||
" mosaic seat task <seat> <text> [--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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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), "<b>x</b>", "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: "<script>" } }), /MOSAIC_AGENT_NAME/);
|
||||
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "a".repeat(65) } }), /MOSAIC_AGENT_NAME/);
|
||||
assert.throws(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), (err) => !err.message.includes("<script>"));
|
||||
assert.equal(resolveSetBy({ by: "explicit", env: { MOSAIC_AGENT_NAME: "<script>" } }), "explicit", "an explicit value does not consult the environment at all");
|
||||
});
|
||||
|
||||
test("validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change", () => {
|
||||
const root = makeRoot();
|
||||
const seatDir = buildRepoSeat(root, "old");
|
||||
const resolved = resolveSeat("old", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const record = makeRegistration({ resolved, task: "launched task", pid: 1 });
|
||||
assert.equal("taskSetBy" in record, false, "launch does not attribute; only seat task does");
|
||||
assert.equal(record.version, 1);
|
||||
// An old on-disk record, byte for byte, without the field.
|
||||
const path = registrationPath(seats, "old", "repo");
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const oldBytes = JSON.stringify(record, null, 2) + "\n";
|
||||
writeFileSync(path, oldBytes);
|
||||
const loaded = readRegistration(seats, "old", "repo");
|
||||
assert.deepEqual(loaded, record);
|
||||
assert.equal(loaded.taskSetBy, undefined);
|
||||
assert.equal(readFileSync(path, "utf8"), oldBytes, "reading never rewrites (no implicit migration)");
|
||||
assert.equal(findRegistrations(seats, "old").length, 1);
|
||||
// Valid values.
|
||||
for (const ok of ["unknown", "darkwing", "jason@host:1", "a".repeat(64)]) validateRegistration({ ...record, taskSetBy: ok });
|
||||
// Invalid values, never echoed.
|
||||
for (const bad of [null, 7, "", "<b>", "two words", "a".repeat(65), {}]) {
|
||||
assert.throws(() => validateRegistration({ ...record, taskSetBy: bad }), (err) => {
|
||||
assert.ok(err instanceof SeatError);
|
||||
assert.match(err.message, /taskSetBy/);
|
||||
if (typeof bad === "string" && bad.length > 1) assert.equal(err.message.includes(bad), false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
writeFileSync(path, JSON.stringify({ ...record, taskSetBy: "<b>" }));
|
||||
assert.throws(() => readRegistration(seats, "old", "repo"), /taskSetBy/);
|
||||
});
|
||||
|
||||
test("updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const resolved = resolveSeat("myseat", { repo: root });
|
||||
const seats = seatsDir(join(root, "data"));
|
||||
const started = new Date("2026-09-10T10:00:00Z");
|
||||
const record = makeRegistration({ resolved, task: "original", harness: "pi", tmux: { socket: "mosaic-fleet", session: "myseat" }, pid: 1, workspace: "/w", project: "proj", now: () => started });
|
||||
writeRegistration(seats, record);
|
||||
|
||||
const first = updateTask(seats, "myseat", "by darkwing", { setBy: "darkwing", now: () => new Date("2026-09-14T10:00:00Z") });
|
||||
assert.equal(first.taskSetBy, "darkwing");
|
||||
assert.equal(first.startedAt, started.toISOString());
|
||||
assert.equal(first.updatedAt, "2026-09-14T10:00:00.000Z");
|
||||
for (const key of ["seat", "project", "workspace", "tmux", "harness", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "version"]) {
|
||||
assert.deepEqual(first[key], record[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
assert.deepEqual(readRegistration(seats, "myseat", "repo"), first);
|
||||
|
||||
const second = updateTask(seats, "myseat", "by jason", { setBy: "jason", now: () => new Date("2026-09-14T11:00:00Z") });
|
||||
assert.equal(second.taskSetBy, "jason", "a later set replaces the attribution; it is not appended");
|
||||
assert.equal(second.startedAt, started.toISOString());
|
||||
|
||||
const third = updateTask(seats, "myseat", "anonymous");
|
||||
assert.equal(third.taskSetBy, SET_BY_UNKNOWN, "no setBy means unknown, never the previous name");
|
||||
|
||||
// Invalid setBy refuses with exit 4 before anything is written.
|
||||
const before = readFileSync(registrationPath(seats, "myseat", "repo"), "utf8");
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "<b>" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "" }), (err) => err instanceof SeatError && err.exitCode === 4);
|
||||
assert.equal(readFileSync(registrationPath(seats, "myseat", "repo"), "utf8"), before);
|
||||
});
|
||||
|
||||
test("CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte", () => {
|
||||
const root = makeRoot();
|
||||
buildRepoSeat(root, "myseat");
|
||||
const { configPath, dataRoot } = buildConfig(root);
|
||||
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
|
||||
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, MOSAIC_AGENT_NAME: "launcher" }) });
|
||||
assert.equal(launchResult.status, 0, launchResult.stderr);
|
||||
const launched = JSON.parse(readFileSync(path, "utf8"));
|
||||
assert.equal("taskSetBy" in launched, false, "launch --task does not attribute, even with the variable set");
|
||||
|
||||
const run = (args, env) => runCli(["seat", "task", "myseat", ...args, "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, ...env }) });
|
||||
const read = () => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
let r = run(["one", "--by", "darkwing"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by darkwing/);
|
||||
assert.equal(read().taskSetBy, "darkwing");
|
||||
assert.equal(read().task, "one");
|
||||
assert.equal(read().startedAt, launched.startedAt);
|
||||
|
||||
r = run(["two"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by rocko/);
|
||||
assert.equal(read().taskSetBy, "rocko");
|
||||
|
||||
r = run(["three"], {});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /set by unknown/);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
|
||||
r = run(["four"], { MOSAIC_AGENT_NAME: "" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(read().taskSetBy, "unknown");
|
||||
for (const key of Object.keys(launched)) {
|
||||
if (key === "task" || key === "updatedAt") continue;
|
||||
assert.deepEqual(read()[key], launched[key], `field ${key} changed unexpectedly`);
|
||||
}
|
||||
|
||||
const frozen = readFileSync(path, "utf8");
|
||||
r = run(["five", "--by", "<script>alert(1)</script>"], { MOSAIC_AGENT_NAME: "rocko" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /refused: --by/);
|
||||
assert.equal(r.stderr.includes("<script>"), false, "the refusal does not echo the value");
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid --by writes nothing");
|
||||
r = run(["five", "--by", ""], {});
|
||||
assert.equal(r.status, 4);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = runCli(["seat", "task", "myseat", "five", "--config", configPath, "--by"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }) });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /missing value for --by/);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen);
|
||||
r = run(["five"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /MOSAIC_AGENT_NAME/);
|
||||
assert.equal(r.stderr.includes("not a name"), false);
|
||||
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid environment value writes nothing");
|
||||
r = run(["six", "--by", "explicit"], { MOSAIC_AGENT_NAME: "not a name" });
|
||||
assert.equal(r.status, 0, "an explicit --by is the way past a bad environment value");
|
||||
assert.equal(read().taskSetBy, "explicit");
|
||||
assert.equal(read().task, "six");
|
||||
});
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
let board = 'the configured board', palette = 'harbor', mode = 'light';
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
|
||||
const setBy = r => r.taskSetBy ? `<span class="source">set by ${esc(r.taskSetBy)}</span>` : '';
|
||||
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
|
||||
const canReply = r => r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
|
||||
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
|
||||
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const row = id => data?.sessions.find(r => key(r) === id);
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
function theme() {
|
||||
@@ -33,11 +37,11 @@
|
||||
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
|
||||
function cards(rows) {
|
||||
if (!rows.length) return '<p class="muted">Nothing here.</p>';
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}</p><p class="preview">${esc(r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
|
||||
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
|
||||
}
|
||||
function table(rows) {
|
||||
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
function registered(r) {
|
||||
const reg = r.registered;
|
||||
@@ -51,9 +55,11 @@
|
||||
if (!selected) return;
|
||||
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
|
||||
$('inspector-title').textContent = r.agent;
|
||||
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], ['Last activity', r.lastActivity || 'unknown'] ];
|
||||
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
|
||||
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
|
||||
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
|
||||
const receipt = receipts.get(selected);
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>Last assistant text</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>Last error</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : '<p class="muted">reply needs a registered seat</p>'}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
}
|
||||
// Restore by data attribute equality, never interpolate API ids into selectors.
|
||||
function focusSnapshot() {
|
||||
|
||||
@@ -29,12 +29,16 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
const agent = 'agent"[\\<script>', project = '__proto__';
|
||||
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
|
||||
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', taskSetBy: '<img src=x onerror=alert(2)>', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
|
||||
index = { sessions: [rec], counts: { waiting: 1 } };
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
|
||||
// #1511: a hostile setter value is text, in the table and the inspector.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("img").length'), 0);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*<img src=x onerror=alert\(2\)> \(as claimed/);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by <img src=x onerror=alert\(2\)>/);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
|
||||
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
|
||||
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
|
||||
@@ -50,6 +54,29 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
|
||||
// #1511: attribution never enables a reply: a setter with no registration at all still gets no form.
|
||||
const savedRegistration = index.sessions[0].registered;
|
||||
index.sessions[0].registered = null; index.sessions[0].taskSetBy = 'someone';
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*someone/);
|
||||
// R1-U1: null means not applicable (task not selected from a registration); it is never shown as "unknown",
|
||||
// and a previous setter never lingers after the transition.
|
||||
index.sessions[0].registered = savedRegistration; index.sessions[0].taskSetBy = null;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("someone")'), false);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
|
||||
assert.equal(await b.evaluate('document.querySelector("table.sessions td.task").textContent.includes("set by")'), false);
|
||||
// A selected legacy registration (record predates taskSetBy) reads "unknown", distinct from not applicable.
|
||||
index.sessions[0].taskSource = 'registration'; index.sessions[0].taskSetBy = 'unknown';
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown \(as claimed by the caller, not verified\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("not applicable")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by unknown/);
|
||||
index.sessions[0].taskSource = 'first-user-message'; index.sessions[0].taskSetBy = null;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("set by unknown")'), false);
|
||||
index.sessions[0].registered.alive = true;
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
|
||||
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
|
||||
|
||||
@@ -34,6 +34,19 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
|
||||
// #1511: attribution shows for the registered task only, as a claim, and only where the task is the registered one.
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*fixture-setter \(as claimed by the caller, not verified\)/);
|
||||
assert.match(await b.evaluate('document.querySelector("table.sessions tr[data-row=\\"proj/agent1\\"] td.task").textContent'), /set by fixture-setter/);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("table.sessions td.task")].filter(td=>td.textContent.includes("set by")).length'), 1, 'transcript-derived rows carry no attribution');
|
||||
assert.match(await b.evaluate('document.querySelector("#waiting .wait-item p:nth-of-type(2)").textContent'), /set by fixture-setter|Build the fixture task/);
|
||||
// R1-U1: a transcript-derived task is "not applicable", never "unknown", and never borrows the registered setter.
|
||||
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open!=="proj/agent1").focus()'); await b.key('Enter');
|
||||
await wait('!document.querySelector("#inspection").textContent.includes("Registered fixture task")');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("fixture-setter")'), false);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
|
||||
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()'); await b.key('Enter');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("Registered fixture task")');
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
await b.evaluate('document.querySelector("#reply").focus()');
|
||||
await b.call('Input.insertText', { text: 'keep this draft' });
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 { identityOf } from '../../discord/src/journal.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';
|
||||
|
||||
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
|
||||
let board, web, b;
|
||||
try {
|
||||
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
|
||||
writeFileSync(join(dir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
writeFileSync(join(dir, 'STOP'), 'PRIVATE STOP');
|
||||
const sessions = join(root, 'sessions', 'discord-pilot'); 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: '<script>window.injected=true</script> 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 }); }
|
||||
});
|
||||
@@ -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? <script>window.injected=true</script>' }] };
|
||||
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: (state === 'waiting' ? 'Input needed: ' : '') + 'done? <script>window.injected=true</script>' }] };
|
||||
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 <unsafe>' : '' }; },
|
||||
|
||||
@@ -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 <img src=x onerror=alert(1)>');
|
||||
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 }); }
|
||||
});
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user