Repository navigation
feat(lazurio): agents send once per idempotency key, and budíček finds Buddy's thread - #105
Conversation
agentrozjedemeai
left a comment
There was a problem hiding this comment.
Exact-head review of 79c4bcc, relative to 04bf14f.
Blocking: apps/server/src/lazurio/buddySurfaceThread.ts:81-96 returns any existing thread with ID buddy without checking that it belongs to the active Buddy project or uses the Buddy provider instance. If that ID already belongs to another project, the resolver returns the foreign project/thread, and on an archived thread even unarchives it. I reproduced this with the production V2 test layer: created buddy in an unrelated project, added the real Buddy project, then called resolveBuddyThread(); it returned the unrelated project ID. budicek would then post the wake into that unrelated conversation instead of Buddy. Please validate the existing shell against the active Buddy project path and provider instance, fail closed on a mismatch, and add a regression covering an existing foreign-ID collision (including the archived case).
Independent checks on the pinned one-commit diff: git diff --check passed; focused buddySurfaceThread.test.ts 3/3 and agentCli.test.ts + cli.test.ts 26/26 passed; release contract 40/40 passed after installing dependencies in the isolated clone. The extra adversarial probe 1/1 demonstrated the issue. Server/web CI was still in progress at this review.
…uddy's thread Pablo's review of #105 found that resolveBuddyThread returned any existing thread `buddy`, also one in another project or on another instance, and unarchived it if it was archived. budíček would then post a wake into someone else's conversation. The resolver now finds Buddy's project first and takes the thread `buddy` only when it is in that project on the instance `buddy`. Any other thread with the id is left as it is, archived or not, and the route answers 409 `thread-conflict` (`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The same check runs after a create, whose fixed command replays. A regression test creates `buddy` in another project, archived and then not, and checks that both asks are refused and the thread stays in its project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
@immakermatty Exact-head re-review: 752654d, stacked on #104 at 04bf14f. The previous foreign-thread blocker is fixed: the resolver finds Buddy's project first, requires both the matching project ID and provider instance on an existing shell before unarchiving it, and repeats that identity check on the create readback. The regression covers archived and active cross-project ID collisions, repeated refusal, and no thread created in Buddy's project. The route maps the conflict to 409, and the CLI maps it to thread_conflict. The same-project/wrong-instance predicate is present in source but is not a dedicated test case; adding that assertion later would improve coverage, not block this fix.
Independent pinned-checkout verification: git diff --check clean; targeted Buddy thread and CLI suites 30/30; server typecheck exited 0 (nonblocking suggestions); targeted lint clean; release contract via node --test 40/40. GitHub CI on this SHA reports successful server/web compatibility, both archive builds, and Linux launcher install; two Macroscope checks are skipped. No unresolved review threads. This approves the #105 head only; #104 remains an open stack dependency, so do not merge #105 independently before the parent is integrated and current-base gates are repeated.
…ts sender The trace owner's review of #105. In the agent commands' one request namespace a key alone named one message for every thread and every sender, so `t3 thread send` with a key already used for another thread, or by another sender, got the first send's result and sent nothing. The command now hands T3 a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own commands). The digest stays the same across commands, sessions and restarts. A new test runs two separate `t3 thread send --idempotency-key` commands with one key against T3's own MCP tool over the production runtime. The thread gets one message, created by an agent over MCP (createdBy "agent", creationSource "mcp"); the repeat prints the first send's result; another thread with the same key gets its own message. Mutation checks: with the raw key, or without the shared namespace, the tests fail. `t3 thread send` already went through /mcp as an agent, and the test now shows it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send <thread> --mode queue --idempotency-key <key> --message-file - --base-dir ~/.t3-buddy --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agentrozjedemeai
left a comment
There was a problem hiding this comment.
@immakermatty Exact-head re-review of 32fe34d: APPROVED. The length-delimited SHA-256 input binds the key to threadId, T3CODE_ACTING_SENDER, and T3CODE_ACTING_FOR; the shared agent-CLI namespace preserves the digest across command sessions and restarts. I found no other agent CLI path passing a raw clientRequestId into that namespace. The live /mcp test exercises separate command sessions against OrchestratorMcpService and the production V2 runtime: one persisted message on retry, first-result replay, independent delivery to another thread, and createdBy=agent / creationSource=mcp. The user guide and release runbook match the implementation and budicek command. Local verification: 21 focused tests, 40/40 release-contract tests, server typecheck, and git diff --check passed. GitHub CI on this head was still in progress at review time; this approval does not replace required checks or authorize merge/release/deploy.
agentrozjedemeai
left a comment
There was a problem hiding this comment.
@immakermatty Exact-head review of 13296f3 (stacked on #104). CHANGES_REQUESTED:
-
apps/server/src/lazurio/agentCli.ts:657-659: Comparing
state.message.text.trim()withtext.trim()silently treats a different payload as a replay. T3 persists the untrimmed input (Orchestrator.ts:4241,4275), so leading/trailing spaces and newlines may change Markdown, code blocks and instructions. In the production-runtime CLI test, I resent keywake-7with" budíček: úkol doběhl "after sending"budíček: úkol doběhl"; it exited 0 with the original result, notidempotency_conflict(the thread still contains the original text). Compare the exact stored and proposed text, and regress whitespace-only differences as well as an identical repeat. -
apps/server/src/lazurio/agentCli.ts:654-670: A delivered key can be reported as a final refusal on a normal retry because the MCP send checks current thread sendability before replaying its receipt (
ThreadManagementService.ts:754-758). In the same production-runtime test, after the first successful send I archived the thread and repeated the same key/text: the CLI exited 1 withthread_not_sendable, although the persisted message and accepted receipt still exist. This contradicts the promised successful repeat with the original commandId and tells a caller to fix a cause that is irrelevant to its already delivered wake. Consult the accepted receipt/message before treating a pre-dispatch send failure as final; return a truthful delivered outcome for a matching stored message, with the original command ID (and adjust/reconstruct the original result contract as appropriate). Add an archived-after-delivery regression, including no duplicate delivery.
The route itself calls only the receipt read and getThreadRecords, with orchestration:read; I found no mutation there. A successful MCP tool result is backed by ThreadManagementService's durable message/run projection check, so I did not find a false-positive delivery on the normal success path. The docs' claim that every identical keyed repeat returns the first result is false in the archived case above.
Independent frozen-checkout evidence: baseline agent CLI/CLI tests 27/27, release contract 40/40, server typecheck exit 0, git diff --check clean. Each of the two additional adversarial production-runtime tests failed as described; both were reverted locally before this review. Live PR head was rechecked before posting; no unresolved threads. GitHub CI was still in progress. Do not merge/release/deploy on this review.
|
@agentrozjedemeai Thank you. Fixed and answered on 2. Repeat after archiving: fixed. T3 checks the thread before it replays a receipt, as you found. When the send fails but T3's receipt for the key is accepted and its message is the one sent, the command now prints success: 1. Trimmed comparison: kept, with evidence. I am not changing it, because an exact comparison would introduce the defect it is meant to prevent:
If you still see a case where two texts that T3 stores differently compare equal, please name it and I will add it as a test. Verification on
|
agentrozjedemeai
left a comment
There was a problem hiding this comment.
@immakermatty Exact-head re-review of cab095e (stacked on #104): APPROVED. Both findings on 13296f3 are resolved. The t3_thread_send input is OrchestratorMcpPrompt, based on TrimmedNonEmptyString; its decode applies value.trim() before OrchestratorMcpService passes input.message into the persisted message. Therefore comparing stored.text to text.trim() is the matching comparison, not an untrimmed-text false replay. The production-runtime CLI regression sends a trailing-newline message, retries with leading spaces, asserts the same output and only one stored trimmed message; the different-content retry still returns idempotency_conflict. After archiving a delivered thread, the send-state route reads the accepted receipt and persisted message and the CLI reports delivered with the original commandId and messageId, without a second message. I additionally asserted the archived retry runId equals the first send runId in a disposable checkout; that probe passed and was reverted.
Independent verification on the pinned checkout: focused agent CLI, Buddy thread and CLI suites 31/31; additional runId assertion 17/17 in agentCli.test.ts; release contract 40/40 after fetching the pinned upstream tree; targeted lint and format clean; server typecheck exited 0 (only suggestions); git diff --check clean. Live head and base were rechecked before submission, with no unresolved threads or competing review requests. GitHub server/web and darwin archive checks are still pending; this approval is not a merge/release/deploy authorization. #104 is still open, so re-evaluate stack/current-base gates before integrating #105.
…s Buddy's thread budíček wakes Buddy with a message into Buddy's T3 thread (root decision 0210, plan DEV-6676) and sends it again after a crash. It needs the send to happen once per wake, and to know which thread is Buddy's. - `t3 thread send --idempotency-key <key>` passes the key as the MCP tool's clientRequestId. T3 derives the command and message ids from it within the request namespace of the MCP client, which is the client's session, and every agent command signs in with a session of its own. A decorator where server.ts provides the MCP client authenticator puts the agent commands' sessions (label "t3 agent cli") in one namespace, so the same key names the same command and T3's command receipt runs it once, also after a restart. Every other MCP client keeps upstream's namespace. - `POST /api/lazurio/buddy/thread` finds or makes Buddy's one thread: thread `buddy` on the instance `buddy` in Buddy's project (~/Lazurio/.agents/buddy), made by a fixed command so two first asks make one thread, and brought back if it was archived. 404 outside Buddy's T3 Code, 409 without Buddy's project. `t3 lazurio buddy thread --json` asks it as an agent command. - `t3 lazurio capabilities` says both with "threadSendIdempotency" and "buddyThread". The "budíček" label on the message is a claim of the "Buddy za ..." trace and not part of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uddy's thread Pablo's review of #105 found that resolveBuddyThread returned any existing thread `buddy`, also one in another project or on another instance, and unarchived it if it was archived. budíček would then post a wake into someone else's conversation. The resolver now finds Buddy's project first and takes the thread `buddy` only when it is in that project on the instance `buddy`. Any other thread with the id is left as it is, archived or not, and the route answers 409 `thread-conflict` (`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The same check runs after a create, whose fixed command replays. A regression test creates `buddy` in another project, archived and then not, and checks that both asks are refused and the thread stays in its project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts sender The trace owner's review of #105. In the agent commands' one request namespace a key alone named one message for every thread and every sender, so `t3 thread send` with a key already used for another thread, or by another sender, got the first send's result and sent nothing. The command now hands T3 a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own commands). The digest stays the same across commands, sessions and restarts. A new test runs two separate `t3 thread send --idempotency-key` commands with one key against T3's own MCP tool over the production runtime. The thread gets one message, created by an agent over MCP (createdBy "agent", creationSource "mcp"); the repeat prints the first send's result; another thread with the same key gets its own message. Mutation checks: with the raw key, or without the shared namespace, the tests fail. `t3 thread send` already went through /mcp as an agent, and the test now shows it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send <thread> --mode queue --idempotency-key <key> --message-file - --base-dir ~/.t3-buddy --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… worth retrying The Platform's budíček keeps one idempotency key across retries, so it never wakes Buddy twice. It also has to tell when sending again cannot help, so it can stop and tell the person, instead of retrying forever. After a send with --idempotency-key, the command now asks T3 what became of the send. The new overlay route POST /api/lazurio/agent-cli/send-state (scope orchestration:read) reads T3's receipt of the key's command and the message that command made, by the ids T3 derives from the key. The release contract pins that derivation. - A repeat of a delivered key prints the first send's result with its commandId. - A key that already sent another message to the thread fails with `idempotency_conflict` and sends nothing. - A key whose command T3 rejected fails with `command_rejected`. Before, it was a bare `orchestration_error`, and a rejected receipt stays rejected. - Other failures keep their codes. docs/user/agent-cli.md lists the final codes: idempotency_conflict, command_rejected, thread_not_found, thread_not_sendable, invalid_request and the session refusals. It says which of them spend the key. Every other code is worth sending again with the same key. `t3 lazurio capabilities` adds `"threadSendOutcome": true`. The end-to-end test runs the real CLI against T3's own MCP tool over the production runtime. It covers a send, a repeat (same output and commandId), the same key with another text (idempotency_conflict, no second message), a key with a rejected receipt (command_rejected) and a missing thread (thread_not_found). With the conflict and rejection checks removed, it fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not replay it Pablo's review of 13296f3 found that T3 checks a thread before it replays a receipt. After a key was delivered and the thread was archived, a repeat of the same key failed with `thread_not_sendable`: a final code for a wake that had already arrived. When the send fails but T3's receipt for the key is accepted and its message is the one sent, the command now prints success. The output is `"delivery": "delivered"`, with the message's id, its run and the original `commandId`. Nothing is sent again. The conflict check keeps comparing the trimmed text. T3's tool trims the message as it decodes it (OrchestratorMcpPrompt is TrimmedNonEmptyString, whose decode trims), so the stored message is the trimmed text. An exact comparison would call a first send whose file ends with a newline a conflict. The mutation check shows exactly that. The end-to-end test adds: - a first send with a final newline and its repeat with leading spaces: one trimmed message, the same output; - the repeat of a delivered key after the thread was archived: delivered, with the same commandId and messageId and no second message. Without the delivered branch, it fails with thread_not_sendable. The docs say both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cab095e to
687fb73
Compare
…r good wakes him in Telegram instead (#597) budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the t3 on PATH. Without the unit, with a unit of another shape or with that runtime missing or not executable it runs no t3 and says why in Czech: a delegation from the chat ends nezalozen, and a wake already owed waits, logged every round. `t3 thread send` now names the refusals that sending the same key again never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected, thread_not_found, thread_not_sendable, invalid_request and the session refusals capability_denied, runtime_mode_escalation_denied and interaction_mode_escalation_denied. On one of them budíček ends that wake in the chat: it records the code (t3.<key>.odmitnuto and a note in historie) and saves it before anything else, then wakes Buddy through the Kanban card in Telegram, as for a record without the channel. A restart at any later step sends nothing more to T3 Code and completes one card; a re-wake has its own key and tries the chat again. Every other failure keeps its key and is retried, as before. Why: before, a refused wake was retried forever and the person never got the result (LazurioPlatform#597 items 2 and 3). Impact: only records with kanal t3; Telegram records are unchanged. The tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover every final code, the restarts, the failures that may pass, the re-wake and each broken unit or runtime. The docstrings and the README no longer say a refusal kept for good is retried forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uddy's thread Pablo's review of #105 found that resolveBuddyThread returned any existing thread `buddy`, also one in another project or on another instance, and unarchived it if it was archived. budíček would then post a wake into someone else's conversation. The resolver now finds Buddy's project first and takes the thread `buddy` only when it is in that project on the instance `buddy`. Any other thread with the id is left as it is, archived or not, and the route answers 409 `thread-conflict` (`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The same check runs after a create, whose fixed command replays. A regression test creates `buddy` in another project, archived and then not, and checks that both asks are refused and the thread stays in its project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts sender The trace owner's review of #105. In the agent commands' one request namespace a key alone named one message for every thread and every sender, so `t3 thread send` with a key already used for another thread, or by another sender, got the first send's result and sent nothing. The command now hands T3 a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own commands). The digest stays the same across commands, sessions and restarts. A new test runs two separate `t3 thread send --idempotency-key` commands with one key against T3's own MCP tool over the production runtime. The thread gets one message, created by an agent over MCP (createdBy "agent", creationSource "mcp"); the repeat prints the first send's result; another thread with the same key gets its own message. Mutation checks: with the raw key, or without the shared namespace, the tests fail. `t3 thread send` already went through /mcp as an agent, and the test now shows it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send <thread> --mode queue --idempotency-key <key> --message-file - --base-dir ~/.t3-buddy --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r good wakes him in Telegram instead (#597) budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the t3 on PATH. Without the unit, with a unit of another shape or with that runtime missing or not executable it runs no t3 and says why in Czech: a delegation from the chat ends nezalozen, and a wake already owed waits, logged every round. `t3 thread send` now names the refusals that sending the same key again never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected, thread_not_found, thread_not_sendable, invalid_request and the session refusals capability_denied, runtime_mode_escalation_denied and interaction_mode_escalation_denied. On one of them budíček ends that wake in the chat: it records the code (t3.<key>.odmitnuto and a note in historie) and saves it before anything else, then wakes Buddy through the Kanban card in Telegram, as for a record without the channel. A restart at any later step sends nothing more to T3 Code and completes one card; a re-wake has its own key and tries the chat again. Every other failure keeps its key and is retried, as before. Why: before, a refused wake was retried forever and the person never got the result (LazurioPlatform#597 items 2 and 3). Impact: only records with kanal t3; Telegram records are unchanged. The tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover every final code, the restarts, the failures that may pass, the re-wake and each broken unit or runtime. The docstrings and the README no longer say a refusal kept for good is retried forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is own t3 runtime, a wake refused for good goes to Telegram (#597, DEV-6676) (#599) * Handover: read Machines v0.12.191's entry.buddy and record Buddy's origin in the binding (DEV-6676) The vendored lazurio.machine.v1 is now byte for byte Machines v0.12.191's (tag commit 811b3f7c, which carries the merge of #811). It adds only the personal branch's optional entry.buddy {external_origin, listen_port} and forbids the member on the Organization branch. The projection records the origin as the binding's buddyOrigin, which F60's parser already takes (a personal VM only, of the Launchpad's own base host); the loopback port is the gateway's and is not recorded. Why: the Launchpad has to know Buddy's origin to pair his own T3 Code there (LazurioPlatform#597), and the handover is where Machines say how the Machine is entered (F16). This is the Platform's step of #590. Impact: nothing until Machines set PLATFORM_ENTRY_BUDDY_MINIMUM to a release with this change; then the Osobní Prostředí's own shell document names Buddy (F60). The provenance tests are re-pinned; tests/shell-buddy.test.ts covers the projection and the refusals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Buddy: his own t3 is the runtime the unit of his T3 Code names, never the t3 on PATH (DEV-6676) src/buddy/t3-runtime.ts resolves Buddy's own t3 from the ExecStart of ~/.config/systemd/user/lazurio-t3-buddy.service, read as Machines' own reader reads it (t3_buddy_unit_version): exactly one ExecStart whose command is ~/.t3-buddy/runtime/versions/<version>/t3, with a Semantic Versioning version, followed by serve. The unit must be a regular file, never a link, of at most 64 KiB in UTF-8, and the runtime an executable regular file inside those runtimes once links are resolved. Anything else is no runtime, and nothing runs in its place. The home's name (.t3-buddy) moves here from src/buddy/chat.ts, which imports it. Why: Machines' contract says whatever runs `t3 … --base-dir ~/.t3-buddy` runs the runtime the unit names, not ~/.local/bin/t3, which follows the person's Chat T3 and may be newer after an in-app update (LazurioPlatform#597 item 2). The Launchpad's pairing on Buddy's origin uses it next; budíček carries the same rule in Python. Impact: no behaviour changes by itself. tests/buddy-t3-runtime.test.ts covers the unit as Machines render it and every shape and file that is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Launchpad: T3 Code's self-pairing on Buddy's origin pairs his own T3 Code, with his runtime and the standard scopes (#597) The Lazurio T3 web client asks GET /.lazurio/chat/pairing on its own origin, and Machines forward that path from Buddy's origin (buddy.<login>.lazurio.io) to the Launchpad too, with the Launchpad's own Host. A request whose X-Forwarded-Host and X-Forwarded-Proto, which the gateway's Caddy sets from the request itself and never takes from a browser, are exactly the recorded buddyOrigin's (asksForBuddy) is now paired with Buddy's own T3 Code (mintBuddyPairing): the standard `t3 auth pairing create --base-dir ~/.t3-buddy --ttl 60s --label … --json` with the Chat's standardPairingScopes, run by the runtime his unit names, never the operator pairing and never the t3 on PATH. Without that runtime the route answers 409 buddy-t3-missing and runs nothing. Why: otherwise his surface never gets its __Host-t3_session and the bubble shows T3's pairing page (LazurioPlatform#597 item 1). The recorded entry names the origin; the forwarded host only says which recorded origin a request came through, after admission. Impact: every other request, every Machine without buddyOrigin and the click's POST /api/chat/pair pair the Chat exactly as before; the answer parsing is shared (pairingOf). tests/launchpad-chat.test.ts covers Buddy's origin, the look-alikes that pair the Chat, the missing runtime and a failing call. Machines are asked to state and prove the headers in their contract (HumanAndMachine-ai/Machines#825). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Budíček: its t3 is Buddy's own runtime, and a wake T3 Code refuses for good wakes him in Telegram instead (#597) budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the t3 on PATH. Without the unit, with a unit of another shape or with that runtime missing or not executable it runs no t3 and says why in Czech: a delegation from the chat ends nezalozen, and a wake already owed waits, logged every round. `t3 thread send` now names the refusals that sending the same key again never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected, thread_not_found, thread_not_sendable, invalid_request and the session refusals capability_denied, runtime_mode_escalation_denied and interaction_mode_escalation_denied. On one of them budíček ends that wake in the chat: it records the code (t3.<key>.odmitnuto and a note in historie) and saves it before anything else, then wakes Buddy through the Kanban card in Telegram, as for a record without the channel. A restart at any later step sends nothing more to T3 Code and completes one card; a re-wake has its own key and tries the chat again. Every other failure keeps its key and is retried, as before. Why: before, a refused wake was retried forever and the person never got the result (LazurioPlatform#597 items 2 and 3). Impact: only records with kanal t3; Telegram records are unchanged. The tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover every final code, the restarts, the failures that may pass, the re-wake and each broken unit or runtime. The docstrings and the README no longer say a refusal kept for good is retried forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Decisions: F61's addendum: the pairing on Buddy's origin, his own runtime, a final wake refusal (#597) Records what this revision decides for Buddy's T3 Code: the handover's entry.buddy gives the Launchpad his origin (the Platform's step of #590); a self-pairing request forwarded from exactly that origin pairs his own T3 Code with the standard scopes, by the forwarded host after admission; his own t3 is the runtime his unit names, for the Launchpad and budíček alike; and a wake T3 Code refuses for good goes to Telegram. It supersedes F61 point 2's "a wake T3 Code refused for good waits, retried and logged every round", settles the refusal F61 left open, and ends F60's honest limit that no binding has buddyOrigin. The list of final codes is T3 Code's own (docs/user/agent-cli.md of #105), which also names thread_not_sendable and the session refusals besides the four #597 lists; the alternatives table says why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Buddy: his runtime counts only inside a real T3 home and runtimes, never through a link to the Chat's (Pablo's review of #599) Both readers of Buddy's own t3 compared the runtime's real path with the real path of `~/.t3-buddy/runtime/versions` itself. A link on `~/.t3-buddy`, its `runtime` or its `versions` therefore moved the allowed root along with it: with `.t3-buddy -> .t3` and a unit naming `.t3-buddy/runtime/versions/<v>/t3`, the Launchpad's pairing and budíček would have run the Chat's executable. Now the runtimes must be exactly `<real home>/.t3-buddy/runtime/versions` (the TypeScript reader) and `<real parent>/<T3 home>/runtime/versions` (budíček), so the T3 home, its `runtime` and its `versions` are real directories, as Machines' own t3_buddy_paths and t3_buddy_prune require. A link among the runtimes still resolves inside them. The two readers now also read the unit's text alike: a byte order mark stays a character in the Launchpad's decoder too, and budíček splits lines only at a line feed, as systemd and the Launchpad do, instead of at every separator `splitlines()` knows. Tests: tests/buddy-t3-runtime.test.ts and budíček's broken runtimes cover the T3 home linked to the Chat's, `runtime` and `versions` as links, a byte order mark and a U+2028 before the ExecStart; without the root check three budíček cases and the TypeScript case fail. F61's addendum and the README say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Status: ready for review. Overlay row 24, plan DEV-6676: T4 of "Buddy's chat on Lazurio T3 Code with Hermes as an ACP agent". Stacked on #104 (T1, base branch
agent/DEV-6676-buddy-t1-prompt); this PR's diff is only T4. Not merged or released.Why
budíček wakes Buddy when a bot's task ends. With Buddy's chat on T3 Code, the wake becomes a message in Buddy's one T3 thread (the shared constants of the design, §9):
Two things were missing:
t3 thread sendhad no key, so a retry duplicated the wake. The MCP tool behind it,t3_thread_send, does take aclientRequestId. But T3 derives the command id from the key within the request namespace of the MCP client, which is the client's session (client:<sessionId>), and every agent command signs in with a session of its own. A key alone would therefore not hold across commands.What changes
t3 thread send --idempotency-key <key>goes through/mcpas an agent (t3_thread_send, recorded ascreatedBy: "agent",creationSource: "mcp", never as the person's own step) and passes the key asclientRequestId, as a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDERandT3CODE_ACTING_FORof the "Buddy za …" trace, unset for the person's own commands). The same key for another thread, or from another sender, is another message (the trace owner's finding on752654da11, fixed in32fe34d648).apps/server/src/lazurio/agentCliRequests.ts; one decorator whereserver.tsprovides the MCP client authenticator). Sessions with the agent commands' labelt3 agent cliget the namespacelazurio:agent-cli.POST /api/lazurio/buddy/thread(buddySurfaceThread.ts,buddySurfaceThreadRoute.ts, in the overlay's route layer).buddy, on the provider instancebuddy, in full access, in Buddy's project. That project is the Buddy layer~/Lazurio/.agents/buddy(§9), which the Platform's installer adds (P1).buddycounts only in Buddy's project and on the instancebuddy. Any other thread with that id is left as it is, archived or not, and nothing is made over it, so a wake never lands in someone else's conversation (Pablo's finding on79c4bcca68, fixed in752654da11).not-buddyoutside Buddy's T3 Code (T3CODE_LAZURIO_BUDDY=1); 409no-projectwhile Buddy's project is missing, and 409thread-conflictwhile a thread with Buddy's id is someone else's.t3 lazurio buddy thread [--json]asks the route as an agent command:{"threadId", "projectId"}, or{"error": {"code", "message"}}with exit 1 (not_buddy,no_project), or exit 3 when T3 is not running. For this, the agent CLI's connection gets a smallpostRoutefor the overlay's routes.13296f31ff, the Platform's request for budíček). After a send with--idempotency-key, the command asks the new overlay routePOST /api/lazurio/agent-cli/send-state(agentCliSendState.ts, scopeorchestration:read) what T3 recorded: the receipt of the key's command and the message it made, by the ids T3 derives from the key (agentCliSendIds; the release contract pins upstream's derivation).commandId. Where T3 cannot replay it, because it checks the thread first (for example, the thread was archived since), the repeat prints"delivery": "delivered"with the message'smessageId,runIdand the originalcommandId, and sends nothing (cab095e3b6, Pablo's finding on13296f31ff).idempotency_conflictand sends nothing. The text is compared after the same trim T3's tool applies when it decodes the message (OrchestratorMcpPrompt), so spaces or a final newline around the same text are the same message.command_rejected; that used to be a bareorchestration_error, and a rejected receipt stays rejected.t3 lazurio capabilitiesadds"threadSendIdempotency": true,"threadSendOutcome": trueand"buddyThread": true.docs/user/agent-cli.md: the key and the Buddy command;server.ts, which is already a shared seam, so the list of files is unchanged;t3 lazuriosubcommands updated.What does not change
buddy.ts,buddyTrace*,cli/auth). This PR adds nothing there. Until that lands, the wake shows as T3's "Sent by another agent".Verification
agentCli.test.ts, extended in13296f31ffandcab095e3b6): also a first send ending with a newline and its repeat with leading spaces (one trimmed message, the same output), and the repeat of a delivered key after the thread was archived (delivered, the samecommandIdandmessageId, no second message). Without the delivered branch it fails withthread_not_sendable; with an exact text comparison the first send fails withidempotency_conflict. Before that: the same setup runs a send, a repeat (the same output andcommandId), the same key with another text (idempotency_conflict, exit 1, no second message), a key whose receipt T3 recorded as rejected (command_rejected) and a missing thread (thread_not_found). With the conflict and rejection checks removed, the test fails.agentCli.test.ts, new in32fe34d648): the realt3CLI against the real/mcp, with T3'sOrchestratorMcpServiceover the production V2 runtime (layerBuddyServer). Two separateT3CODE_ACTING_SENDER=budicek t3 thread send <thread> --mode queue --idempotency-key wake-7commands, each with its own session, leave one message in the thread,createdBy: "agent"andcreationSource: "mcp"; the repeat prints the first send's result; the same key to another thread delivers its own message. Mutation checks: with the raw key, or without the shared namespace, this test fails.lazurio:agent-cli; an MCP session fromt3 auth session issuekeepsclient:<session>.buddySurfaceThread.test.ts, production V2 runtime frombuddySurface.testkit.ts):buddyin another project, first archived and then not, is refused withthread-conflictboth times and stays where it is;agentCli.test.ts: the realt3CLI, the real/mcpwith T3's access checks, the authenticator wrapped asserver.tswraps it):t3 lazurio buddy thread --jsonprints{"threadId":"buddy","projectId":"project-buddy"}twice and makes the thread once; it exits 1 withno_projectandnot_buddy;cli.test.tspins the new capabilities output. The T1 prompt test now uses the shared testkit and still passes.cab095e3b6: 43 files passed and 1 skipped (CDP), 492 tests passed and 13 skipped. The thread test file: 4 tests.vp fmt --check,vp lintand server typecheck are clean.T3CODE_ACTING_SENDER=budicek t3 thread send buddy --mode queue --idempotency-key wake-e2e-4711 --message-file - --base-dir <home> --jsonruns against a running dev server returned the samemessageIdand run, and Buddy's Hermes got one turn.Orchestrator.dispatchWithReceiptEffect) is now covered by the end-to-end test above.Codes for budíček
With
--idempotency-key,t3 thread send --jsonprints{"threadId", "messageId", "runId", "status", "delivery", "commandId"}on success and the same on every repeat of a delivered key. Where T3 cannot replay the result, it prints{"threadId", "messageId", "runId", "delivery": "delivered", "commandId"}. A failure prints{"error": {"code", "message"}}. These codes are final, so sending again as it is never helps:idempotency_conflictcommand_rejectedthread_not_foundthread_not_sendable--mode steerfound no running turn.invalid_requestcapability_denied,runtime_mode_escalation_denied,interaction_mode_escalation_deniedEverything else is worth sending again later with the same key, which never sends twice:
server_not_running(exit 3),request_failed,auth_unavailable,orchestration_error.docs/user/agent-cli.mdhas the same list.budíček's command
--message-file -reads the message from standard input (the only stdin form), and--base-diris the T3 home flag every agent command takes. The variable is the trace's (#106); with this PR it also scopes the key.Risks and follow-ups
t3 agent cli. An MCP OAuth client that registers that exact name would share it. It would need the person's approval first, and could then already message every thread.HOMEhere. feat(lazurio): Buddy's T3 Code is Buddy's one conversation, framed by the bubble #107 (T2) resolves it from the managed Folder (T3CODE_LAZURIO_FOLDER=<Folder>/.agents/buddy), with this path as the fallback.🤖 Generated with Claude Code