Skip to content

feat(agents2): plugin agent types on a new Agents2 page - #693

Merged
baxen merged 1 commit into
mainfrom
murderbot/agents2
Oct 8, 2026
Merged

baxen merged 1 commit into
mainfrom
murderbot/agents2

Conversation

@baxen

@baxen baxen commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Combines Logan's agent-attention/v1 coplan spec with the plugin agent-type idea from #621. It adds a new Agents2 page and leaves the existing harness agents untouched. No agent types ship with the app; real agent types come in follow-up plugins.

What's here

  • Agent types are plugins. A type registers on ctx.agents2: { id, title, defaults, run, CardBack, tabs }.
  • Local store. It matches the agent-attention/v1 objects: interest/<id> and watch/<id> slugs, each { value, modifiedAt }. Next to them is a plugin-owned config. Each write changes exactly one object, behind a small interface, so a relay backend can replace localStorage later.
  • Runtime.
    • Event watches are matched against the owner's live relay stream (spec steps 1–4). Directly addressed events go to a mention trigger instead.
    • Timers follow the spec's schedule rules: armed_at, interval, max_occurrences, expires_at, and no burst catch-up. Timer state is stored separately from config.
    • Classifier watches are skipped and shown as unsupported.
  • Minimal run lifetime. run({ trigger, agent, config, signal }) gets one stable agent handle (pubkey, name, owner, publish) and an AbortSignal that fires on timeout or agent removal. Runs execute one at a time per agent. There is no per-run identity fencing (unlike Add plugin agent types: agents a plugin defines and the app runs #621).
  • Native identity. crates/agent-controller/src/app_agent.rs plus the Tauri commands in src-tauri/src/app_agents.rs.
    • Keys live in the OS credential store and require the owner's NIP-OA attestation.
    • Only kinds 0/5/7/9/40003 are signed, with size bounds, and posted via NIP-98 <relay>/events.
    • Channel events (with an h tag) get an ms tag from the same clock read as created_at, so agent replies sort after their trigger in the timeline.
    • Desktop only; the browser shows an unavailable state.
  • Agents2 page.
    • A grid with a "New agent" card first and no running/stopped states.
    • Hovering a card flips it to the type's CardBack.
    • Clicking a card opens an identity column next to the type's tabs (details and edit). The app adds the Attention tab.
    • With no agent types installed, the page shows an empty state and hides New agent.
  • Profile panel. If the pubkey is an Agents2 agent, the panel shows the type's tabs and hides Memories. Otherwise it is unchanged.

FOUNDATION files (edited per baxen's direction; please review closely)

Checks (at 717b6772)

  • pnpm check passes (biome, typecheck, design checks).
  • Full vitest run: 566 files, 8,251 tests pass.
  • cargo clippy --workspace --locked --all-targets -- -D warnings passes; cargo test -p buzz-agent-controller passes.
  • Browser journeys that failed in CI now pass locally in chromium and webkit (26 tests): profiles, profile-instances, profile-channel-focus, layout (page order now includes Agents2), and image-scroll:52. No browser cases were added or removed; the profiles fixture now provides an Agents2 service because the profiles plugin injects it.
  • baxen tested the page and the agent flow in the desktop app with an earlier example type (since removed).

Deferred (follow-ups)

  • Agents with different owners can trigger each other in a loop; only the 60/min rate limit slows that.
  • If a plugin reloads while jobs are queued, those jobs are dropped.
  • Real LLM agents need streaming host.fetch, stored secrets, a live run view and a workspace, plus Agents2 types for plugin authors.
  • Relay storage for attention, classifiers, and migrating harness agents.

@baxen
baxen marked this pull request as ready for review October 8, 2026 00:58
@baxen
baxen requested review from a team, comp615 and wesbillman as code owners October 8, 2026 00:58
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T01:03:31.351756Z 9ad3a2c Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ad3a2c3f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

export const inject = ["pages", "agents2", "relay"];
export const apply: PluginModule["apply"] = (ctx) => {
const { agents2, relay } = ctx;
ctx.pages.register({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register the bundled responder agent type

The enabled-by-default plugin registers only the Agents2 page. A repo-wide search of production sources finds no call to ctx.agents2.register, so the stock app always has an empty types() snapshot, hides the New agent action, and cannot create or run an agent; only tests register synthetic types. Register the promised responder here or ship another enabled bundled provider.

AGENTS.md reference: AGENTS.md:L94-L97

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one: the PR deliberately ships without an agent type (see the description, and commit 5ed469d). Real types come in follow-up plugins, and until one is installed the page shows an empty state.

Comment on lines +192 to +196
.position(|agent| agent.pubkey == pubkey)
.map(|index| agents.remove(index)))
})?;
match removed {
Some(agent) => credentials.delete(&agent.credential_id(), &agent.pubkey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delete the credential before forgetting the identity

When OS credential deletion fails or is denied, update has already removed the identity from identities.json, so the command reports an error but the next launch prunes the local record while the private key remains orphaned in the credential store. Delete the credential first—or restore the identity on failure—so this destructive operation remains retryable.

AGENTS.md reference: AGENTS.md:L10-L12

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: remove now deletes the key first and forgets the identity only after that succeeds. Covered by a_failed_key_deletion_keeps_the_identity_for_a_retry.

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +321 to +326
const identity = await native.commit(pubkey, auth);
this.identities = [
...this.identities.filter((saved) => saved.pubkey !== pubkey),
identity,
];
this.write({ ...record, pubkey });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back native creation when local persistence fails

If writeRecords throws after native.commit—for example because localStorage is full, unavailable, or the plugin supplied a non-serializable config—the native identity and key have already been committed but no record exists. Subsequent loads retain that identity while never displaying it, leaving an agent that cannot be managed or deleted through the page. Validate persistence before committing where possible and remove the newly committed native identity if the record write fails.

AGENTS.md reference: AGENTS.md:L10-L12

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: the local record is written before native.commit, so a quota or serialization failure happens before anything is committed. If native commit fails afterwards, the record stays. It is hidden while it has no identity, it lets the owner manage any identity native may have left behind, and the next load prunes it if there is none.

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +320 to +326
const auth = await native.authorize(pubkey);
const identity = await native.commit(pubkey, auth);
this.identities = [
...this.identities.filter((saved) => saved.pubkey !== pubkey),
identity,
];
this.write({ ...record, pubkey });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fence creation against relay-session changes

If the user changes community or account while native authorization or commit is pending, creation continues using the captured old binding, but this.write reconciles against the new binding. The new identity is therefore persisted for the old community, this.find(pubkey) returns undefined, and the page's onCreated path then fails while an unexpected hidden agent remains. Recheck the session/scope after each awaited native step and clean up a committed identity when its originating binding has retired.

AGENTS.md reference: AGENTS.md:L89-L92

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a86967d, following the smaller fix from the other thread: create returns the committed agent built from its identity and record, without depending on the current snapshot. The agent stays saved in the community it was created for, and the page opens it only if agents2.find still sees it; otherwise it goes back to the grid. Covered by returns the created agent after the community changes mid-create.

Comment thread src/features/agents2/react.ts Outdated
Comment on lines +34 to +39
agents2 && agent
? () => agents2.types().find((type) => type.key === agent.type)
: none,
none,
);
return agent && type ? { agent, type } : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep recognizing agents whose type plugin is unavailable

When an agent-type plugin is disabled or unloaded, the saved agent still exists but this hook returns undefined solely because its type lookup failed. The profile panel consequently treats the identity as a harness agent, exposes harness-only tabs such as Memories, and omits even the host-owned Attention section, despite the Agents2 page explicitly supporting unavailable types. Return the agent with an optional type so the panel preserves Agents2 behavior across plugin lifecycle changes.

AGENTS.md reference: AGENTS.md:L89-L92

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: useAgent2 returns { agent, type } with an optional type, so the panel keeps Attention and hides Memories. Covered in react.test.tsx.

@baxen
baxen force-pushed the murderbot/agents2 branch 2 times, most recently from 717b677 to 1c49853 Compare October 8, 2026 01:05

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: preserve retryability across native key deletion/creation and local-record failures; handle stale Create completion; retain Attention for unavailable types; and fix rename cancellation and skipped-object preservation. Details and smallest fixes are inline.

Star Lord automated source review via Wes’s account. Head 1c49853928e62ef1cf9cc43b3606a25bf9a0371b; base 50f5b82c0f3595efbc368446beba770146420fc1 (PR diff from merge base 5aeeda7ecd723eef84212c4578611f59412d6af8). Source-only: no code, tests, builds, app/native workflows, or CI revalidation executed. Public PR description inspected; it contains no attached images. This COMMENT is not an approval or merge authorization.

Comment on lines +189 to +194
let removed = self.update(|agents| {
Ok(agents
.iter()
.position(|agent| agent.pubkey == pubkey)
.map(|index| agents.remove(index)))
})?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Retain the identity until credential deletion succeeds

update writes the identity removal before credentials.delete runs. A denied/busy/unavailable credential store therefore leaves the key behind but removes the only persisted mapping needed to delete it. The dialog offers a retry, yet the next call takes None => Ok(()), so the UI reports deletion without attempting to remove that key again. Keep the identity (or a retryable deletion record) until exact-key deletion succeeds; cover a failing deletion followed by retry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: the key is deleted before the identity is forgotten, so a refused delete keeps the mapping and Retry deletes the key again. Covered by a_failed_key_deletion_keeps_the_identity_for_a_retry (a failed delete, then a retry).

Comment on lines +175 to +180
let id = agent.credential_id();
credentials.add(&id, &prepared.key)?;
agent
.read_key(credentials)
.map_err(|_| "New agent key could not be verified")?;
self.update(|agents| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve recovery when saving a new identity fails

After credentials.add succeeds, either readback or the identity-file write can fail. The key remains in OS storage, while app_agent_create_commit has already removed the prepared request from its pending list and consumes it here. Retrying Create generates a different key; the previous key has neither an identity entry nor a retry path. Retain the same prepared identity for a retry, or compensate the key write and retain recovery information if cleanup fails. Add fault coverage after key storage and before identity persistence.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: commit now saves the identity before the key. If the add or readback fails, it deletes the key and withdraws the identity only once the delete succeeds. If the delete fails too, the identity stays so remove can retry. Fault coverage is in a_failed_key_save_leaves_no_orphaned_key (add refused, readback refused, readback and delete refused).

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +321 to +326
const identity = await native.commit(pubkey, auth);
this.identities = [
...this.identities.filter((saved) => saved.pubkey !== pubkey),
identity,
];
this.write({ ...record, pubkey });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recover a committed identity when the local record write fails

The native identity/key are already durable when write calls localStorage.setItem. A quota/storage error rejects Create, but there is no rollback or recoverable record: after restart, update ignores that native identity because it has no local record, so the owner cannot manage/delete it through Agents2. write also mutates this.records before persistence, allowing a later update to expose the failed write in the current session. Make creation recoverable across this boundary (persist a recoverable record or compensate the native commit), and only replace the in-memory record map after a successful storage write.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: the record is persisted before native commit, so every committed identity has a record to manage it by. write now replaces this.records only after setItem succeeds. Covered by commits no identity when its record cannot be saved and keeps the record of a create whose native commit fails….

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +327 to +330
// The agent exists from here on; a missing profile only leaves it unnamed
// for others until its next rename.
await this.publishProfile(pubkey);
return this.find(pubkey) as Agent;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not resolve Create from the currently selected community

A community switch or disconnect while native commit/profile publication is pending clears or changes the visible snapshot. The identity and record can still be saved successfully in the original community, but find(pubkey) then returns undefined; the assertion does not change that at runtime. Agents2Page immediately reads agent.pubkey, so the completion throws instead of returning the agent; a still-mounted create form treats that as a failure and offers another Create. Return the committed result independently of the current filtered snapshot, and suppress stale navigation in the page when its creation scope/opening has changed. Cover a switch during the awaited operations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: create returns the committed agent independently of the filtered snapshot. onCreated opens Build only when the agent is in the community still shown (it checks live with agents2.find) and otherwise returns to the grid. Tests: returns the created agent after the community changes mid-create (service) and stays on the grid when the new agent belongs to a community no longer shown (page).

Comment thread src/features/agents2/react.ts Outdated
: none,
none,
);
return agent && type ? { agent, type } : undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recognize saved Agents2 agents without an active type plugin

Disabling/removing a type plugin makes this hook return undefined even though agents2.find(pubkey) still returns the saved agent. ProfilePanel then omits the app-owned Attention tab and can show the harness Memories tab for that identity. This also happens during plugin replacement. Return the agent with an optional type: agentSections already accepts an unavailable type and can keep Attention usable, just as the Agents2 Build view does. Cover a saved agent whose type is disabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: useAgent2 returns the saved agent with an optional type. agentSections already handles an unavailable type, so the profile panel keeps Attention and never shows Memories for it. Covered in react.test.tsx.

Comment on lines +607 to +609
if (event.key === "Escape") {
setDraft(name);
event.currentTarget.blur();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Prevent Escape from committing the cancelled rename

setDraft(name) is queued, but blur() synchronously dispatches onBlur={commit} before this handler finishes. That callback still closes over the edited draft, so Escape saves the name it was meant to discard. The local reset can then be overwritten again by the saved agent update. Suppress the blur commit for this cancellation (or make it read the reset value synchronously), and add a keyboard regression asserting that Escape never calls onSave.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: Escape sets a ref that the blur handler checks, so it skips that one save. Regression test: saves a rename on Enter and discards it on Escape checks that onSave is never called on Escape.

Comment on lines +643 to +645
let id = stem;
for (let n = 2; `${space}/${id}` in agent.attention; n++) id = `${stem}-${n}`;
return `${space}/${id}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reserve skipped slugs when choosing a new object ID

readRecords moves invalid/over-limit objects into agent.skipped, but new-object naming checks only agent.attention. If a skipped watch/messages exists, Add watch with its default name reuses that slug; setAttention removes the skipped object and replaces its saved value without the owner choosing Fix or Remove. The same issue affects interests and timers. Check both attention and skipped keys when selecting a free slug, with coverage showing that adding a new object preserves a skipped object of the same stem.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a86967d: freeSlug now treats both attention and skipped slugs as taken. Covered by never names a new object after a skipped one it would replace (a skipped watch/messages leads to watch/messages-2).

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two P2 runtime blockers remain; details and bounded fixes are inline. The seven findings from the earlier review are addressed by this delta; I am not duplicating them. The P3 notes are optional, not additional merge criteria. Shipping without a bundled agent type is intentional and is not a defect.

Reviewed head a86967d7d3213ff862ad051cc0b4d1300aeb1c9c, base tip 50f5b82c0f3595efbc368446beba770146420fc1, merge base 5aeeda7ecd723eef84212c4578611f59412d6af8. Validation: source/call-path review, two focused production-service reproductions, and 19 isolated UI fix/regression checks by the delegated reviewer. No live native/Keychain/relay acceptance or broad local-suite rerun. Hosted JavaScript/Rust checks passed; browser journeys were still running at closeout. Separately, DCO fails because the new commit lacks a sign-off; that metadata failure is not the reason for this verdict.

Merge criteria: address both P2 behaviors with deterministic regression coverage and finish the existing required checks.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Comment thread src/features/agents2/service.ts Outdated
[object.slug]: {
...state,
used: state.used + 1,
nextDue: now + timer.interval_secs,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Coalesce pending timer occurrences and revalidate them before execution

nextDue advances when a job is enqueued, even if that timer's previous run is still holding the runner. Every later tick queues another occurrence, and drain executes all of them without checking the current timer. I reproduced this against this head with a 1-second timer expiring at +30s: hold its first run, remove the timer at +55s, then release the run. Start offsets were [5000, 55100, 55100, 55100, 55100] ms: four back-to-back runs after both expiry and deletion. This violates the promised no-burst schedule and lets removed automation continue acting.

Keep at most one outstanding occurrence per timer, advance the next schedule relative to actual execution, and before starting queued timer work recheck that the current timer still exists, is enabled, and is unexpired. Cover a held run spanning several intervals plus expiry/removal.

Comment thread src-tauri/src/app_agents.rs Outdated
let credentials = state.2.clone();
let (agent, key) = blocking(move || {
let agent = agents.get(&pubkey)?;
agent.read_key(credentials.as_ref()).map(|key| (agent, key))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle credential contention before dropping an agent publication

Every publish calls read_key on the same Arc<PlatformCredentials> shared by all Agents2 agents. On macOS, this reaches Bundle::access, whose mutex try_lock fails immediately with Credentials are busy; the cross-process bundle lock also uses try_lock. The existing same_process_contention_reports_busy_without_blame_or_keychain_access test explicitly encodes that behavior.

Two agents responding concurrently (or a response overlapping agent creation/harness startup) therefore cause one publish to fail before HTTP dispatch whenever their credential operations overlap. This command propagates the error, and the runner only logs it; there is no retry/outbox for the lost reply. Serialize or boundedly retry the credential-acquisition phase, preserving exact identity/deletion checks. Do not blindly retry the later HTTP phase, where acceptance may be ambiguous. Add a gated contention test proving both publications reach dispatch. This is a source-traced integration defect; I did not exercise live Keychain access.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Cold reads are covered now (79efed31). AppAgentHost::key retries a Credentials are busy read every 100 ms for up to 5 s before dispatch. It releases the key-cache lock between attempts, so agents whose keys are already cached never wait. Any other refusal fails immediately, and the HTTP phase is never retried. Test: app_agents::tests::a_first_key_read_waits_out_a_busy_store.

Comment thread src/features/agents2/service.ts Outdated
const record = this.records[pubkey];
if (!record || !this.native) return;
try {
await this.native.publish(pubkey, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, optional] Preserve ordering when replacing the agent profile

A rename publishes kind 0 through Secret::signed, which uses the current wall-clock second without advancing past the previous profile. Two renames within one second can therefore leave the first name current under the lowest-event-ID tie break; a backwards clock adjustment has the same problem. The local name is already saved, so other users can continue seeing the old name. The existing harness profile flow handles this with current-profile lookup, sign_event_after, and confirmation. Reuse that replacement-ordering contract here. This is lower priority than the runtime blockers; it was source-traced, not exercised against a relay.

{
label: "Channels",
options: [
{ value: "all", label: "Every channel it can read" },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, optional] Disclose that “Every channel” also includes global events

The “Any event” / “Every channel it can read” combination stores channels: "all", kinds: []. watchMatches then skips the h check entirely, while subscribeLive also supplies presence, profiles, read-state, and other non-channel routes. A focused service probe with one read-state event, 70 presence events, and an addressed message produced 33 watch runs and dropped the mention because these share the same queue.

If channels: "all" intentionally includes global traffic, label/disclose that scope rather than presenting it as channel-only; otherwise constrain this UI selection to channel events. I could not locate attention spec r16 in the repository or PR, so this is a UI/intent clarification, not a demand to change the protocol semantics.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: one new deletion-lifecycle defect is inline. The prior timer-queue and credential-contention findings remain unchanged; this delta only adds relay cleanup before deletion. The new commit also lacks the repository-required Signed-off-by trailer.

Star Lord automated source review via Wes’s account. Focused follow-up at head acb82861d76e004d00ce385f0ca3e41ff4901d25, base 50f5b82c0f3595efbc368446beba770146420fc1. Source/call-path review only: no tests, builds, app/native workflows, or CI revalidation executed. Public description and commit messages inspected; no description images attached. COMMENT only, not approval or merge authorization.

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +398 to +403
const consent = archives.writable
? await archives.consent(pubkey, signal)
: null;
if (consent) await archives.request("archive", pubkey, signal);
}
await this.native.remove(pubkey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Fence key deletion when the relay session retires

If the community is switched or disconnected while archives.ensure() / refresh() is pending, session disposal aborts that read but its promise resolves. state() then becomes unknown, and the closed archive service’s consent() returns null. This code treats that as an agent without a profile and proceeds to native.remove, permanently deleting the key without confirming archival. The timeout signal is not tied to session lifetime, and the initial binding check is never repeated. Revalidate the captured binding/session after awaited relay steps and immediately before native deletion; distinguish a retired/unavailable archive service from a confirmed missing-profile case. Add a gated regression that retires the session during archive refresh and asserts the key/record remain available for retry.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: two new P2s inline for stale-profile overwrites and keyboard focus lost after a successful publishing retry. The earlier timer/credential findings and deletion-session finding remain unchanged. The latest commit also lacks the required Signed-off-by trailer.

Star Lord’s automated source review via Wes’s account. Focused follow-up at head df9468095894e1d5cfb65087fde3010bd1caf049, base 50f5b82c0f3595efbc368446beba770146420fc1. Source-only: no tests, builds, app/native flows or CI revalidation; live profile ordering, deletion and keyboard acceptance remain unverified. Public description inspected; no attached images. COMMENT only, not approval.

Comment thread src/features/agents2/service.ts Outdated
Comment on lines +474 to +475
await session.profiles.ensure([pubkey]);
current = session.profiles.event?.(pubkey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Read a fresh profile before preserving its other fields

session.profiles.ensure() only fetches missing identities (profile-directory.ts:85–89); once this agent is cached, it performs no read. If another client/type updates the picture or about while the profile live route is recovering or its update has not arrived, renaming here republishes the older cached fields and overwrites that update. native.publish does no current-profile lookup or merge. Use a fresh, successful read of this agent’s current kind 0 as the replacement base, retaining profilePending on read failure rather than treating the display cache as authoritative. Add a regression with an already-cached profile and a newer relay profile whose non-name fields must survive the rename.

Comment thread src/features/agents2/AgentManage.tsx Outdated
}) {
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState("");
if (!agent.profilePending) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Hand off focus before a successful retry removes its button

A keyboard user can keep focus on Retry publishing while it is loading (the shared Button deliberately preserves focus). On success, sendProfile clears profilePending and notifies subscribers, so this return removes the focused button and its entire region. Neither this component nor AgentBuild transfers focus, leaving the user at the document rather than a stable agent control. Hand focus to a stable destination only if this retry still owns focus immediately before removal; do not steal focus if the user moved elsewhere. Cover pending, repeated failure, success and moving away during the request.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: three P2 regressions are inline—timer starvation, retries missed on normal reconnect, and deletion racing the initial profile publication.

The credential-contention finding is only partly resolved: cached/same-host access is serialized, but a cold key read still fails immediately on the shared bundle lock held by harness startup or another app. Add bounded pre-dispatch acquisition recovery. The previously noted DCO gap also remains on a86967d7, acb82861, and df946809; the newest commit is signed off.

Star Lord’s automated source review via Wes’s account. Head 7cda820b79e57f7a828d265c0be8271836e4939a; base 50f5b82c0f3595efbc368446beba770146420fc1. Source-only: no tests, builds, app/native workflows, or CI revalidation. Public description inspected; no attached images. COMMENT only, not approval.

Comment thread src/features/agents2/service.ts Outdated
continue;
}
}
if (!due) continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give other due timers a turn before repeating the first

dueTimer always scans from the first attention entry and returns its first due timer. If that recurring timer's run lasts longer than its interval, it is already due again whenever drain calls this function, so a second enabled, overdue timer never runs—even with no queued events. The periodic tick cannot advance the second timer while the first run holds runner.running. Select the oldest due timer or rotate eligible timers while retaining the new expiry/removal checks and one-occurrence rule. Add a regression with two retained timers where the first run repeatedly exceeds its interval and assert that the second progresses.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in b9e56fee. dueTimer now picks the due timer that has waited longest (the earliest nextDue) instead of the first one in attention order. The expiry/removal checks and the one-waiting-occurrence rule are unchanged. Regression test: "gives each due timer a turn when one's runs outlast its interval". Each run takes three intervals, and the test asserts the two timers alternate. It fails on 7cda820.

);
this.binding = live;
this.update();
if (live) this.sync();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Retry synchronization on the actual live-connection recovery path

This sync() runs only when the binding/session changes; refresh() is otherwise triggered by identity mutations. Normal WebSocket recovery happens inside the existing session (relay/live.ts reconnects and session.live notifies), without replacing RelayData.session or notifying this service through relay.subscribe. A transient failed profile publication or deletion cleanup therefore stays failed after the connection recovers, potentially indefinitely while the user keeps the community open. The publishing Retry control has also been removed. Subscribe to the existing live recovery signal, or retain an explicit visible retry; test recovery with the same session rather than the current fake's cloned session.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in b9e56fee and 79efed31. The service now subscribes to session.live and re-runs sync() whenever the status goes from not-connected back to connected in the same session. If a cleanup is still running when that happens, it runs again if that attempt fails. The fake session now has a live status, and recover() drops and restores it without replacing the session. Tests: "retries a failed profile and cleanup when the live connection recovers in the same session" and "retries a cleanup that fails after the connection recovered while it ran". Both fail without the fix.

Comment on lines +525 to +529
state === "not-archived" && (await archives.consent(pubkey, signal));
signal.throwIfAborted();
if (consent) await archives.request("archive", pubkey, signal);
}
await this.native?.forget(pubkey);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Settle the first profile publication before forgetting a deleted identity

Create now returns while sync()'s native profile POST is still in flight. Delete removes the cached key, but the running app_agent_publish_profile already owns an Arc<Secret> and its HTTP request is neither cancelled nor joined. For a non-admin owner, cleanup can read no profile yet, get null consent, and forget the tombstone; the delayed initial POST can then be accepted and leave the deleted agent visible in the directory with no cleanup record left. Keep the tombstone and coordinate with already-admitted profile publication before treating profile absence as final, then perform the archive decision. Cover a held first profile POST completing after Delete starts; immediate key removal can remain unchanged.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in b9e56fee. The profile lock is now per agent, and native Delete takes that agent's lock before it removes the key. A profile POST already in flight therefore finishes (accepted or refused) before Delete returns, and before cleanup reads the profile to decide on archiving. The tombstone handling is unchanged. Rust test: app_agents::tests::delete_waits_for_a_profile_publication_in_flight, which fails if the lock is removed. One edge is still open: a POST that times out client-side after the relay already accepted it.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes needed: the cleanup retry still loses a request when the community binding changes during an in-flight attempt (P2 inline). Timer fairness, bounded credential acquisition, and same-host profile/delete ordering have source fixes. The previous DCO finding remains; the same three commits are unsigned. One optional deterministic-test note is inline.

Star Lord’s automated source review via Wes’s account. Head 79efed316a8517e07fde0e98bdfcf6f769836cc4; base be28af6f170c9ac99dce60d96d1c45f0c19f1958. Source-only; no tests, builds, or native/live workflows executed. One CI snapshot: DCO failed, other jobs still running; Windows skipped. Public description inspected; no attached images. COMMENT only, not approval.

} finally {
const again = this.cleaning.get(pubkey);
this.cleaning.delete(pubkey);
if (failed && again) void this.cleanup(pubkey, binding);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Hand the queued cleanup retry to the current binding

The new again flag is discarded when the old binding was cancelled: failed becomes false, so the retry requested by a replacement binding never runs. For example, cleanup can be awaiting archives.ensure()/refresh() (which do not take this cleanup signal) while the user switches away and returns to the community. Communities retain that session; the return calls sync(), records again = true, and leaves the old attempt running. When its archive read settles, throwIfAborted() reaches this branch and drops the pending retry. With the returned session already connected, no further recovery edge is guaranteed, leaving the deleted agent's archival unfinished until another unrelated refresh/reconnect.

After releasing the in-flight slot, honor a queued request using the current, non-aborted binding only if the tombstone still belongs to its owner/community; do not retry with the cancelled binding. Add a gated archive-read regression that returns to the community before the old attempt settles. This is the remaining overlap case of the earlier synchronization-recovery finding.

});
// Unblocked, deleting an absent agent takes well under this.
let waited = std::time::Duration::from_millis(300);
assert!(tokio::time::timeout(waited, &mut deleting).await.is_err());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, optional] Make the deletion-order regression independent of runner speed

This negative assertion assumes an unlocked delete of an absent agent completes within 300 ms. The test never observes the spawned task reaching the profile gate (or its spawn_blocking work), so a slow executor can make it pass even if the production lock is removed. Use an explicit admission/completion gate to establish the operation reached the boundary, assert it remains blocked, then release publication and verify deletion finishes. This is a test-evidence improvement, not an additional production blocker.

@baxen
baxen force-pushed the murderbot/agents2 branch 2 times, most recently from bb9a9cb to 275a556 Compare October 8, 2026 23:21
Adds an Agents2 page where agent types come from plugins. The app owns each
agent's identity (its key lives in the OS keychain and signs only a small set
of event kinds), what it pays attention to (mentions, event watches, timers),
and when it runs. The plugin decides what a run does. No agent type ships in
this change; saved agents of a missing type show as unavailable.

Squashed from the 21-commit agents2 series on murderbot/agents2, which was
rebased onto main. The original commits were written by the agents below.

Co-authored-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz>
Co-authored-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Signed-off-by: Bradley Axen <[email protected]>
@baxen
baxen force-pushed the murderbot/agents2 branch from 275a556 to f0d5996 Compare October 8, 2026 23:32
@baxen
baxen merged commit 23ae0c0 into main Oct 8, 2026
19 of 22 checks passed
@baxen
baxen deleted the murderbot/agents2 branch October 8, 2026 23:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants