mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
ea4890c25758a0c6a26047a0ba14ce2506ef1881
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ea4890c257 |
fix(pairing): address review — docs, error mapping, approve feedback, unreadable expiry
- docs: device.pair.update and the `permanent` option on approve in docs/04-gateway-protocol.md, docs/19-websocket-rpc.md and websocket-protocol.md; the paired-device TTL row in docs/09-security.md now mentions the admin opt-out. - store.ErrPairedDeviceNotFound: SetPairingPermanent wraps it in both stores. device.pair.update maps it to NOT_FOUND and any other store error to INTERNAL, so a DB failure no longer reads as "not found". - web UI: approve and make-permanent/set-expiry now toast the server error and reload the list in `finally`. A partially applied approve (paired, but the permanent write failed) shows up in the table instead of leaving the dialog dead-ended. - SQLite ListPaired: a stored expiry that fails to parse stays 0 (expires, date unknown) rather than being mistaken for permanent; the UI renders it as "--" instead of a 1970 date. Tests: gateway handler error mapping (NOT_FOUND / INTERNAL / OK), sentinel checks in the PG and SQLite store tests, SQLite unreadable-expiry case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5379cc1163 |
feat(pairing): let an operator make a device pairing permanent
Every approved pairing expires 30 days after approval (pairedDeviceTTL),
and nothing lets an operator change that: owners of a personal setup have
to re-pair their own Telegram account and browser every month. The schema
already treats a NULL paired_devices.expires_at as "never expires" (IsPaired
and the prune both check for it), only no code path ever writes NULL.
- store: SetPairingPermanent(senderID, channel, permanent) on PairingStore,
PG and SQLite. permanent=true clears expires_at, false restarts the
default TTL from now. An already expired pairing is not revived.
PairedDeviceData gains expires_at (Unix ms, null = never), returned by
ApprovePairing and ListPaired.
- gateway: device.pair.approve accepts `permanent`; new admin RPC
device.pair.update {senderId, channel, permanent} for existing pairings,
classified in permissions/policy.go next to the other pairing methods.
- mcp: pairing approve tool accepts `permanent`.
- web UI (Nodes): "Never expires" switch in the approve dialog, an
Expires column, and a Make permanent / Set expiry action per device.
ConfirmDialog takes optional children for the switch.
The default stays 30 days; permanence is an explicit operator choice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f0b7571fce |
fix(teams): scope team tasks to the delegation origin, not the delivery channel (#1530)
* fix(teams): scope team tasks to the delegation origin, not the delivery channel
A delegated run is delivered on the internal "delegate" channel while its real
origin is preserved separately — buildAgentLinkRunRequest is explicit about it:
// preserves the origin's authorization-bearing identity while keeping
// delegation on its internal delivery channel.
Channel: "delegate",
WorkspaceChannel: req.Channel,
WorkspaceChatID: req.ChatID,
The team tools did not consult that origin, so a task created by a lead reached
through delegate was stamped with the delivery channel. Nothing is registered
for it, so every notification about that task — completion, failure, blocker
escalation, ask_user — was dropped:
unknown channel for outbound message channel=delegate
The delegatee's first answer still arrived, because it travels back as the
delegation result rather than through a channel; everything the lead said after
the delegation closed was lost. One session produced 16 such drops, including a
blocker escalation the user needed to see.
Add OriginChannelFromCtx / OriginChatIDFromCtx next to the existing workspace
scope propagation helpers, and use them where team scoping and notification
routing are decided: task records, list/search scoping, dispatch fallbacks,
event payloads, escalation tasks, ask_user and leader notifications. The
resolution is the identity when no delegation origin is present, so
non-delegated flows are unchanged.
Deliberately not touched: the channel used in authorization decisions
(checkTeamAccess, requireLead, approve/reject lead bypass). Those ask "how did
this call arrive", not "where should the answer go", and widening them is a
separate question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(teams): cover delegated-lead completion routing and origin isolation
Triage on #1529 named two gates this PR had not met: regression coverage for
delegated lead task completion, and a guard that unrelated origins cannot
receive the notification. The existing tests only covered what create persists.
TestDelegatedLeadCompletionNotifiesOrigin completes a task raised in a delegated
context and asserts the completion event is addressed to the caller's origin.
Reverting team_event_helpers.go to the delivery channel fails it with
"addressed to delegate/system".
TestCompletionNotificationStaysWithinItsOwnOrigin puts two tasks from different
origins on one board, completes one, and asserts no completion notification
carries the other origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
305bd7c50d |
fix(teams): grant a delegated lead read access to its own team (#1537)
* fix(teams): scope team tasks to the delegation origin, not the delivery channel
A delegated run is delivered on the internal "delegate" channel while its real
origin is preserved separately — buildAgentLinkRunRequest is explicit about it:
// preserves the origin's authorization-bearing identity while keeping
// delegation on its internal delivery channel.
Channel: "delegate",
WorkspaceChannel: req.Channel,
WorkspaceChatID: req.ChatID,
The team tools did not consult that origin, so a task created by a lead reached
through delegate was stamped with the delivery channel. Nothing is registered
for it, so every notification about that task — completion, failure, blocker
escalation, ask_user — was dropped:
unknown channel for outbound message channel=delegate
The delegatee's first answer still arrived, because it travels back as the
delegation result rather than through a channel; everything the lead said after
the delegation closed was lost. One session produced 16 such drops, including a
blocker escalation the user needed to see.
Add OriginChannelFromCtx / OriginChatIDFromCtx next to the existing workspace
scope propagation helpers, and use them where team scoping and notification
routing are decided: task records, list/search scoping, dispatch fallbacks,
event payloads, escalation tasks, ask_user and leader notifications. The
resolution is the identity when no delegation origin is present, so
non-delegated flows are unchanged.
Deliberately not touched: the channel used in authorization decisions
(checkTeamAccess, requireLead, approve/reject lead bypass). Those ask "how did
this call arrive", not "where should the answer go", and widening them is a
separate question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(teams): cover delegated-lead completion routing and origin isolation
Triage on #1529 named two gates this PR had not met: regression coverage for
delegated lead task completion, and a guard that unrelated origins cannot
receive the notification. The existing tests only covered what create persists.
TestDelegatedLeadCompletionNotifiesOrigin completes a task raised in a delegated
context and asserts the completion event is addressed to the caller's origin.
Reverting team_event_helpers.go to the delivery channel fails it with
"addressed to delegate/system".
TestCompletionNotificationStaysWithinItsOwnOrigin puts two tasks from different
origins on one board, completes one, and asserts no completion notification
carries the other origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(teams): grant a delegated lead read access to its own team
Fixes #1535. Second attempt: the first one set RunRequest.TeamWorkspace on the
delegated run, which loop_context.go rejects outright and deliberately so — that
field also becomes ToolWorkspace, which would displace the exchange outputs
directory. Every delegation then died at setup with "invalid delegation artifact
workspace". This does not go near that field.
The separation the lead needs already exists in the code. A lead addressed
directly resolves its team at loop_context.go:285-321 and gets
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
with no WithToolWorkspace call — a read allowance, not an override. Only the
!isArtifactDelegation gate keeps a delegated lead out of it. So the grant is
made inside the artifact branch instead, from the same inputs, and the guard on
req.TeamWorkspace stays exactly as strict as it was.
Both paths are needed. The workspace alone covers only the lead's own chat leaf;
the deliverable in the report sits at teams/<id>/system/review-....md, written by
a member under a different chat scope. buildAllowedPrefixes adds the team root
for reads and not for writes (filesystem.go:366-370), which is precisely the
asymmetry this case wants: the lead reads the team's output, per-chat write
isolation is untouched.
Team ID is deliberately not set. It switches on the workspace interceptor's
write validation, file-change broadcast and task attachment (workspace_
interceptor.go:36,114,203) — none of which a delegated run should trigger, and
none of which reading needs.
Ambiguity is not resolved silently, as triage asked: an agent leading more than
one active team gets nothing. store.GetTeamForAgent would answer in one call but
it is ORDER BY (lead_agent_id = $1) DESC LIMIT 1 and would quietly pick a team.
Hermeticity is unchanged: send_file and message still refuse inside an artifact
run, publication still happens through delegation outputs on completion.
Tests: TestInjectContext_DelegatedLeadReadsTeamWithoutDisplacingOutputs drives
injectContext through the guard that broke the first attempt and pins both
halves — the run is not refused, and ToolWorkspace stays the outputs directory.
Resolution rules, the shared/isolated path shapes, read-not-write on the team
root, and send_file staying blocked are covered alongside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(teams): pin that the delegated lead resolver does not re-scope by tenant
l.dataDir arrives already tenant-scoped from resolver.go (config.TenantDataDir),
which is why resolveDelegatedLeadTeamRead must not apply TenantLayer itself.
That was carried only by a comment, here and in the sibling branch of
injectContext, and nothing failed if someone added the layer back.
The failure it guards against is quiet: the path stays plausible, it just gains
a second tenant segment — teams/<id> under tenants/<slug>/tenants/<slug> — so
the lead is handed a directory its own tasks never write to, and reads come back
empty rather than denied.
Verified the assertion has teeth by reintroducing the layer: the subtest fails
with "tenant segment appears 2 times". Prompted by #1546 in this same area,
where a contract that lived outside the unit under test went unpinned and the
feature shipped green and did nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
60cf79de51 |
feat(teams): let a human cancel and retry stuck team tasks from the dashboard (#1559)
* feat(teams): let a human cancel and retry stuck team tasks from the dashboard A task that ended up blocked, stale, failed or cancelled could not be recovered from the UI: the dashboard only deletes terminal tasks and approves/rejects in_review ones. CancelTask and ResetTaskStatus existed in the store but were reachable only through the lead agent's team_tasks tool, which needs the full task UUID the dashboard never shows (#506). Two WS RPCs, wired to Retry / Cancel buttons in the task detail dialog: - teams.tasks.cancel — any task not yet completed/cancelled. Optional reason is stored as the result and posted as a comment so the lead sees it on the board. Dependents are unblocked by the store; they are not dispatched here because the dashboard has no agent turn. - teams.tasks.retry — stale / failed / cancelled / in_review, plus blocked tasks that nothing blocks any more. A human comment is required: it is posted on the task and appended to the assignment prompt, so the assignee gets the missing answer in the same message. Optional agentId reassigns; the lead is refused as assignee (same guard as teams.tasks.assign). ResetTaskStatus (pg + sqlite) now also accepts blocked, guarded on the handler side by an empty blocked_by. Handler tests cover both actions with a stub store: reason/comment persistence, required comment, status gating, blocked_by guard, lead guard, cross-team IDOR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(permissions): classify teams.tasks.cancel and teams.tasks.retry as write methods Every WS method must be classified in internal/permissions/policy.go, otherwise the router answers UNAUTHORIZED for every role. Caught on a live gateway (the drift test TestMethodRole_DriftCoverage… flags it, I had not run that package). Both sit next to approve/reject/assign as operator-level write methods; the write-methods test now pins them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(teams): let the retry dialog pick the assignee A cancelled or stale task may have no owner (created unassigned, or the member was removed). teams.tasks.retry then needs agentId, which the dialog did not send, so Retry would fail with "task has no assignee". The retry dialog now shows an assignee select (team members minus the lead, defaulting to the current owner) and passes agentId only when it differs from the owner. Retry is offered only when there is someone to pick. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
34cc6e011d |
test(teams): fix the data races in the teammate streaming test (#1556)
TestHandleTeammateMessageSchedulesStreamedRun fails CI intermittently under
-race. Two separate races, both in the test rather than in what it exercises:
1. It shared `gotReq` between the scheduler's RunFunc and the assertions. The
RunFunc runs on a scheduler goroutine, and the announce loop schedules a
second run after the teammate one, so the write could land while the test
was reading — and the second run also closed an already-closed channel.
Requests now arrive over a buffered channel: no shared state, and a second
run cannot clobber the first.
2. The deferred sched.Stop() ran while handleTeammateMessage's background
goroutine was still calling Schedule, so Lane.Submit's wg.Add raced
Lane.Stop's wg.Wait. The gateway drains BgWg before stopping the scheduler
(gateway_consumer.go waits on it; sched.Stop is an outer defer in
gateway.go); the test skipped that step. It now drains too, which also makes
the test match the shutdown order it is meant to represent.
Reproduced before the fix with `-race -count=60 -cpu=1,4` (fails within a few
iterations) and clean afterwards over `-count=200 -cpu=1,2,4`, plus the whole
cmd package under -race.
Nothing in production changes; `Stream: true` and the channel-manager assertion
are untouched.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d98ea89e3b |
feat(teams): show the full task UUID in the task detail dialog with one-click copy (#1560)
The short identifier (T-015-cc8e) carries only the last four hex chars of the UUID, while every agent-facing surface — the team_tasks tool (get / retry / cancel / comment) and the teams.tasks.* RPCs — takes the full UUID. A human looking at the dashboard therefore had no way to name a task to the lead in chat. Show the UUID next to the identifier and status badges, copy it to the clipboard on click, with a short "copied" confirmation. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0e3eb57317 |
fix(delegate): add action=list so a lost delegation ID is recoverable (#1546)
* feat(delegate): add action=list, scoped to the originating chat Fixes #1545. A delegation result was addressable only by the UUID returned once in a tool result, which the calling model had to carry forward by hand. One mistyped character orphaned a completed, durably stored result with no way back: `get` answers "delegation result not found", and there was nothing else to ask. Observed in production with a 31B-class caller — one flipped character, and separately a splice of the previous delegation's tail onto the next one's prefix. `spawn`, the sibling async mechanism over the same table, has had list/wait/cancel all along; `delegate` had delegate/get. Scope is tenant and calling agent, as get already resolves, plus the origin chat. The chat rather than the session, for three reasons: - It survives a session reset. Deferring long work, clearing the context and coming back to ask for status is ordinary use; a SessionKey predicate would return nothing exactly then — when the handle is most likely already lost. - It keeps chats apart, which is the enumeration boundary #1525 is about: there spawn's list filters on the parent agent key alone and ignores the session, so one chat reads another chat's task text. - It does not carry a conversation between chats. A delegation raised in a team chat stays visible in that team chat and does not surface in someone's DM with the same agent. History stays where it began. In a direct chat that separates users as well, since the chat ID is per person. Group chats deliberately show the group what the group started. get is left as it was, deliberately. #1525 is an enumeration defect — no prior knowledge needed and task text is disclosed. get is access through an unguessable handle, and adding a predicate there would break fetching a result by an ID kept across a reset, which is the very failure this fixes. No schema change: the origin fields are already persisted by createDelegateCompletion. ListByParent is filtered in Go behind a cap of 20, which suits handle recovery; a dedicated predicate would be the next step if this ever needs to page. Tests pin the chat boundary, the session-reset case, refusal when there is no chat to scope to (without querying the store), and the cap. The fake store leaves ListBySession embedded and nil, so a refactor back to session scoping panics rather than passing quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(delegate): list needs its own store query — ListByParent excludes delegations The action shipped in 506ecba4 always returned an empty list. It read through SubagentTaskStore.ListByParent, whose SQL carries AND COALESCE(metadata->>'completion_kind', 'subagent') <> 'delegate' ListBySession carries the same clause. Both serve spawn and filter delegations out on purpose, so no listing in the store could return one — only Get by ID reaches a delegation. The feature was a no-op in production while its unit tests were green, because the fake store returned whatever rows the fixture supplied and never reproduced the predicate that does the damage. Found by running it against a live cluster: an async delegation was created, `get` returned it completed with its result, and `list` reported zero. Adds ListDelegationsByChat to the interface and to both implementations, with the inverse predicate plus origin_chat_id, and points the tool at it. Chat scope and delegate-only selection now live in the query rather than in a Go filter over whatever the store happened to return; an empty chat yields no rows instead of falling back to everything. Tests are where the fix matters most: - internal/store/sqlitestore exercises the real SQL. It pins that ListDelegationsByChat returns the chat's delegation, that a spawn in the same chat is not one, that another tenant's identically named chat stays invisible, and — the part that would have caught this — that ListByParent and ListBySession still do not return delegations, so the complementarity is documented rather than assumed. - the tool's fake now panics if ListByParent or ListBySession is called, so a regression to either fails loudly instead of quietly listing nothing, and it applies the chat and kind predicates itself so fixtures behave like the store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e1674b0249 |
fix(teams): dispatch team tasks created by detached child runs (#1528)
Team tasks are not dispatched inline: the turn's PendingTeamDispatch collects them and the post-turn drain assigns and dispatches them once the turn ends. That same tracker also owns the per-(team, chat) create lock taken by team_tasks list/search. Async delegations and async spawns keep the caller's context values (context.WithoutCancel) but run detached — after the caller's turn has already ended and drained. A team lead reached through delegate therefore adds every task it creates to a tracker nobody will drain again, and takes a create lock nobody will release. Two symptoms follow: - tasks stay pending forever and are never dispatched, so the team never starts work. The task ticker does not cover this: it never dispatches pending tasks, it only marks them stale after 2h and asks the lead to retry by hand. - the next team_tasks list/search for the same (team, chat) blocks on the never-released mutex for the remaining lifetime of the process. Give each detached child run its own tracker through the existing InjectTeamDispatch helper, drained when that run ends. The synchronous delegate/spawn paths are deliberately left alone: they execute inside the caller's turn, where the caller's tracker is the correct owner. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7e3f25eeb |
test(teams): pin the teammate streaming contract
Review on #1533 asked for coverage of the behavioural contract rather than the field assignment, and that is the right call — the PR text arguing a test would only restate the assignment was wrong, because the contract has two halves that a refactor could break independently. The test drives handleTeammateMessage through a real scheduler and asserts both: the scheduled run requests streaming, and the run is not registered with the channel manager, so its chunks cannot be routed to a user. It fails with Stream reverted to false. The remaining half — a streamed call still yielding the complete final response — is already covered in internal/agent/loop_pipeline_callbacks_test.go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
34a1540c18 |
fix(teams): stream teammate runs so long generations keep the connection alive
handleTeammateMessage pinned Stream: false, so every team member run — coder,
reviewer, researcher — made a non-streamed provider call. On a slow reasoning
model that means a silent connection for the whole generation, and
ResponseHeaderTimeout eventually kills it:
iter 0 think: llm call: litellm: request failed:
Post ".../v1/chat/completions": http2: timeout awaiting response headers
Observed on a member asked to write a large single-file page: 15 minutes, zero
output tokens, run failed. The same model over the same provider is fine on an
ordinary channel run, which streams by default.
Streaming here is for connection liveness, not delivery. A teammate run is never
registered with the channel manager, so HandleAgentEvent returns on its first
line and the chunks are dropped; nothing reaches a user incrementally. The task
result is unchanged — it still comes from the final RunResult, since ChatStream
returns the complete response once the stream ends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|