Skip to content

feat: international daily-activity turn over the console agent channel - #52

Merged
maiphucgiang merged 7 commits into
maiphucgiang:mainfrom
ytzzjx:pr/international-daily-activity
Oct 4, 2026
Merged

maiphucgiang merged 7 commits into
maiphucgiang:mainfrom
ytzzjx:pr/international-daily-activity

Conversation

@ytzzjx

@ytzzjx ytzzjx commented Oct 3, 2026 •

Copy link
Copy Markdown

What this adds

An automated daily-activity turn for international WorkBuddy (intl-work)
accounts.

WorkBuddy grants a daily activity reward (30 credits, settled next day) for real
client use. A gateway /v2/chat/completions call does not earn it — that is a
bare inference path with no product-side usage record. The web console drives an
agent session instead, so this reproduces that path: create a console
conversation, attach to its sandbox over ACP, drive one turn to completion.

Why it needs a dedicated path

app/acp_client.py (227 lines) is the only long-lived connection in the project:
one SSE GET that yields the Acp-Connection-Id, plus one short JSON-RPC POST
per method. Two deliberate choices worth reviewing:

  • http.client, not httpx. The channel has to stay open for the whole turn
    while console polling proceeds on separate connections, and its bytes are
    drained opportunistically so a read can never block the caller.
  • Client capabilities are declared off (fs read/write false,
    terminal false). Advertising them would make the sandbox wait for callbacks
    it will never receive, and the session would never finish. This is invisible
    from the outside, so it is worth a comment rather than a guess.

app/daily_chat.py (410 lines) is the orchestration: one turn per account per
local day under a durable reservation.

Safety properties

  • The commit point sits immediately before the prompt POST. A failure that
    spent nothing — an unready sandbox, a channel that never connected — stays
    retryable for the rest of the day; a sent-but-unconfirmed turn is never
    replayed, because replaying it could spend credits twice.
  • Opt-in, default off, matching auto_checkin / auto_travel: a turn can
    spend credits on the account.
  • Completion is read back from the console, never from the SSE stream. The ACP
    spec returns a stopReason on the prompt response, but this implementation
    answers the HTTP call before the turn ends, so protocol state is not business
    state.
  • Cost is measured, not inferred — from the protocol-native usage_update,
    not from a balance delta.

Verified live

Three accounts ran a turn; all three were credited +30 the next day (official
CreateTime on the reward segment, 04:49 / 04:49 / 05:07 the following morning).
These were the first code_007 segments those accounts had ever received, so the
grant cannot be confused with pre-existing credit. Measured cost per turn: 0.0.

Tests

tests/test_daily_chat.py — 47 offline tests, no upstream calls. They pin the
safety properties rather than the happy path: one turn per day, no replay of an
unconfirmed send, an unsent day stays retryable, no upstream text or credential
path escapes into a result, and the ACP client declares no capabilities.

Full suite after these changes: 1434 passed, 11 failed. Those 11 are
test_sqlite_state.py / test_terminal_output.py PTY/TTY tests that fail
identically on unmodified main in a container without a controlling terminal —
they are environmental, not related to this change.

Five defects fixed while verifying

Each has a regression test:

  1. An AcpError stranded the day. It bypassed the reservation cancel, so the
    record stayed reserved while the next call treats anything other than
    cancelled as unretryable pending — a channel that never delivered the
    prompt still cost the whole day. One cancel_unless_sent() helper now serves
    every failure branch.
  2. The day key followed the process timezone. With a UTC container the
    once-per-day guard turned over at 08:00 Beijing, so a local day could be
    visited twice and a restart near UTC midnight could skip one. One today()
    helper now serves the sweep, the manual action and the inventory view.
  3. Every account fired in the same minute. due_at() derives a stable
    per-account slot inside a six-hour window from identity + day. A hash beats a
    random offset, which would move on every sweep and could starve an account.
  4. prune_daily_chats was never called, so the table grew one row per account
    per day forever.
  5. The sandbox is provisioned asynchronously and answers with a link before it
    is ready, so a single read raced provisioning. It is now polled briefly, and
    the conversation id is recorded before the sandbox is used so a later failure
    can be told apart from one that never reached the agent.

Notes for reviewers

  • No new runtime dependency; http.client and select are stdlib.
  • intl-cli, cn-cli and cn-work are explicitly unsupported and return
    unsupported without touching the network.
  • A live-verified fact that shaped the design: the vendor's own
    checkin-activity-status endpoint still reports active: false after the reward
    has been paid, so it cannot be used as a success signal. The reward segment
    appearing in get-user-resource is the only trustworthy check.

Summary by Sourcery

Implement an opt-in international WorkBuddy daily activity turn that runs through the console agent channel and tracks each account’s outcome safely.

New Features:

  • Add an opt-in daily activity turn for international WorkBuddy accounts through the console agent channel.
  • Expose manual and scheduled daily activity controls and status in the admin API and credentials UI.

Bug Fixes:

  • Prevent replay of sent-but-unconfirmed turns and keep failures before prompt submission retryable.
  • Correct local-day handling, stagger account execution, retry asynchronous sandbox provisioning, and prune old activity records.

Enhancements:

  • Add durable per-account daily activity state with completion, usage, and conversation tracking.
  • Add bounded ACP transport support with sanitized outcomes and protocol-native usage reporting.
  • Restrict the feature to international WorkBuddy accounts and keep it disabled by default.

Build:

  • Set the container timezone configuration used by local-day activity guards.

Tests:

  • Add offline coverage for daily activity orchestration, ACP protocol behavior, safety guarantees, persistence, API actions, and UI integration.

WorkBuddy's international daily-activity reward is not granted for a
/v2/chat/completions call: that endpoint is a bare inference path with no
product-side usage record. It is granted for real client use, so this drives the
same console conversation the web app uses, over ACP.

app/acp_client.py is the only long-lived connection in the project: one SSE GET
carrying the Acp-Connection-Id, plus one short JSON-RPC POST per method. It uses
http.client rather than httpx on purpose, so the channel can stay open while
console polling proceeds on separate connections and a read can never block the
caller. Client capabilities are declared off deliberately — advertising fs or
terminal support would make the sandbox wait for callbacks it will never get.

app/daily_chat.py runs one turn per account per local day under a durable
reservation. The commit point sits immediately before the prompt POST, so a
failure that spent nothing (an unready sandbox, a channel that never connected)
stays retryable while a sent-but-unconfirmed turn is never replayed. Cost is read
from the protocol-native usage_update rather than inferred from a balance delta.

The switch is opt-in and defaults off for international accounts, matching
check-in and travel: a turn can spend credits on the account.
Five fixes found while verifying the feature against live accounts:

- An AcpError escaping perform() left the reservation at 'reserved', and the next
  call treats any phase other than 'cancelled' as an unretryable 'pending'. A
  channel that never delivered the prompt therefore cost the whole day even though
  it spent nothing. One cancel_unless_sent() helper now serves every failure
  branch; only a record already 'sent' is preserved.

- The day key came from time.strftime, which follows the process timezone. Where
  that is UTC the once-per-day guard turned over at 08:00 Beijing, so a Beijing
  day could be visited twice and a restart near UTC midnight could skip one. One
  today() helper now serves the sweep, the manual action and the inventory view.

- The sweep fired every opted-in account in the same minute. due_at() derives a
  stable per-account slot inside a six-hour window from identity+day, so the
  accounts never go out together and the order still changes daily. A hash beats a
  random offset, which would move on every sweep and could starve an account.

- prune_daily_chats was defined and tested but never called, so the table grew one
  row per account per day forever.

- The sandbox is provisioned asynchronously and answers with a link before it is
  ready, so a single read raced provisioning. It is now polled briefly, and the
  conversation id is recorded before the sandbox is used so a later failure can be
  told apart from one that never reached the agent.
The daily guards (check-in, travel, daily-activity) all key on a local calendar
day, but a container without TZ runs on UTC, so those guards turn over at 08:00
Beijing rather than at local midnight — a local day can be visited twice and a
restart near UTC midnight can skip one.

The default stays UTC, so an existing deployment is unchanged; operators in
another zone set TZ once.
@sourcery-ai

sourcery-ai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Adds an opt-in international WorkBuddy daily-activity turn that reproduces a console agent session over a bounded ACP channel, persists a conservative account/day state machine to prevent double-spending, schedules and surfaces the task through maintenance and the admin UI, and verifies the safety-critical paths with offline tests.

Sequence diagram for the international daily activity turn

sequenceDiagram
    participant Sweep as MaintenanceSweep
    participant DailyChat as daily_chat.perform
    participant Store as ControlStore
    participant Console as WorkBuddyConsole
    participant ACP as AcpChannel

    Sweep->>DailyChat: perform(token, profile, ...)
    DailyChat->>Store: daily_chat_record(identity, day)
    Store-->>DailyChat: available or retryable reservation
    DailyChat->>Store: reserve_daily_chat(identity, day)
    DailyChat->>Console: create_conversation()
    Console-->>DailyChat: conversation_id
    DailyChat->>Store: daily_chat_checkpoint(conversation_id)
    loop sandbox provisioning
        DailyChat->>Console: sandbox_of(conversation_id)
        Console-->>DailyChat: link, token, session_id
    end
    DailyChat->>ACP: run_turn(link, token, session_id, cwd, prompt)
    ACP->>ACP: post(initialize)
    ACP->>ACP: post(session/load)
    DailyChat->>Store: transition_daily_chat(..., sent)
    ACP->>ACP: post(session/prompt)
    loop bounded completion polling
        ACP-->>DailyChat: drain() usage_update
        DailyChat->>Console: status_of(conversation_id)
        Console-->>DailyChat: completed
    end
    DailyChat->>Store: transition_daily_chat(..., confirmed)
Loading

State diagram for the daily chat reservation lifecycle

stateDiagram-v2
    [*] --> reserved: reserve_daily_chat
    reserved --> cancelled: unsent failure
    reserved --> sent: transition_daily_chat
    sent --> confirmed: completed console status
    sent --> sent: unconfirmed failure
    confirmed --> reconciled: reconcile
    cancelled --> reserved: retry same day
    sent --> [*]: no automatic replay
    confirmed --> [*]
    reconciled --> [*]
Loading

File-Level Changes

Change Details Files
Added a bounded ACP transport for driving one console-agent turn without exposing upstream content or advertising unsupported callbacks.
  • Maintains one long-lived SSE connection and short JSON-RPC connections for initialize, session/load, and session/prompt.
  • Drains SSE opportunistically and extracts only protocol-native usage updates.
  • Uses disabled filesystem and terminal capabilities and sanitizes protocol failures.
app/acp_client.py
Implemented the international WorkBuddy daily-activity orchestration with durable one-shot execution and conservative failure handling.
  • Creates and polls console conversations and sandboxes, then confirms completion from console status rather than ACP response state.
  • Commits immediately before prompt submission; unsent failures are cancellable while sent-but-unconfirmed turns remain non-retryable for the day.
  • Records conversation, sandbox, usage-before/after, and ACP usage details while preventing credential or upstream text leakage.
  • Restricts execution to intl-work, supports manual actions, and adds stable per-account daily scheduling.
app/daily_chat.py
app/credential_actions.py
app/gateway_management.py
app/model_policy.py
converter.py
Added durable storage and lifecycle management for daily activity reservations.
  • Introduces one account/day record with reservation, sent, confirmed, cancelled, and reconciled phases.
  • Adds guarded transitions, checkpoints, local-day validation, retryable cancellation, and pruning of old diagnostic rows.
  • Persists the opt-in preference with a default-off policy.
app/control_store.py
app/model_policy.py
converter.py
Exposed daily activity support, status, controls, and cost information through the administration API and web UI.
  • Adds API fields and PATCH handling for the opt-in setting plus a manual daily-chat action.
  • Displays eligibility, current-day status, confirmation details, and measured ACP cost.
  • Adds frontend response validation and localized labels.
app/admin_api.py
app/credential_actions.py
app/gateway_management.py
web/src/api.ts
web/src/pages/Credentials.tsx
web/src/values.tsx
Added regression coverage for protocol behavior, safety invariants, scheduling, persistence, and failure containment.
  • Covers one-turn-per-day behavior, no replay after send, retryability before send, unsupported profiles, generation changes, storage failures, and sanitized results.
  • Covers ACP sequencing, disabled capabilities, usage extraction, sandbox polling, local-day keys, stable staggering, and pruning.
tests/test_daily_chat.py
Configured the container timezone used by local-day scheduling.
  • Adds a configurable TZ environment variable with UTC as the compose default.
docker-compose.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 security issue, and 1 other issue

Security issues:

  • Avoiding SQL string concatenation: untrusted input concatenated with raw SQL query can result in SQL Injection. In order to execute raw query safely, prepared statement should be used. SQLAlchemy provides TextualSQL to easily used prepared statement with named parameters. For complex SQL composition, use SQL Expression Language or Schema Definition Language. In most cases, SQLAlchemy ORM will be a better option. (link)
Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="app/control_store.py" line_range="26-31" />
<code_context>
     return value


+def _day(value, label="日期"):
+    """Accept only a YYYY-MM-DD local day; never a timestamp or free text."""
+    if (not isinstance(value, str) or len(value) != 10 or value[4] != "-" or value[7] != "-"
+            or not value.replace("-", "").isdigit()):
+        raise ValueError(f"{label} 无效")
+    return value
+
+
</code_context>
<issue_to_address>
**nitpick (bug_risk):** `_day` accepts impossible calendar dates such as `2026-99-99` and `2026-02-31`; those values can be stored as daily-chat keys and later treated as distinct valid days.

**Triggers:** When a caller passes a syntactically valid but calendar-invalid day to a ControlStore daily-chat method.

**Suggested fix:** Parse the value with `datetime.date.fromisoformat` and reject values that are not real calendar dates.

```suggestion
def _day(value, label="日期"):
    """Accept only a YYYY-MM-DD local day; never a timestamp or free text."""
    import datetime

    if (not isinstance(value, str) or len(value) != 10 or value[4] != "-" or value[7] != "-"
            or not value.replace("-", "").isdigit()):
        raise ValueError(f"{label} 无效")
    try:
        datetime.date.fromisoformat(value)
    except ValueError:
        raise ValueError(f"{label} 无效") from None
    return value
```
</issue_to_address>

### Comment 2
<location path="app/control_store.py" line_range="509-514" />
<code_context>
            updated = self._db.execute(
                "UPDATE daily_chats SET phase=CASE WHEN phase='reconciled' AND ?='confirmed' THEN phase ELSE ? END, "
                "confirmed_at=CASE WHEN ? IN ('confirmed','reconciled') THEN COALESCE(confirmed_at,?) ELSE confirmed_at END, "
                "updated_at=? WHERE account_key=? AND day=? AND attempt_id=? AND phase IN ("
                + ",".join("?" for _ in expected) + ")",
                (phase, phase, phase, time.time(), time.time(), identity, day, attempt, *expected))
</code_context>
<issue_to_address>
**security (python.sqlalchemy.security.sqlalchemy-execute-raw-query):** Avoiding SQL string concatenation: untrusted input concatenated with raw SQL query can result in SQL Injection. In order to execute raw query safely, prepared statement should be used. SQLAlchemy provides TextualSQL to easily used prepared statement with named parameters. For complex SQL composition, use SQL Expression Language or Schema Definition Language. In most cases, SQLAlchemy ORM will be a better option.

*Source: opengrep*
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and this adds an opt-in automated console agent turn that creates an external conversation, sends a prompt, and may consume account credits; a sent turn or spent credits cannot be undone by reverting the change. The daily reservation bounds the impact to one attempt per account per day, so the harm is bounded but still requires human review.

Blocking findings: app/control_store.py:514


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread app/control_store.py
Comment thread app/control_store.py
The daily-turn turn landed two defects that only surface when the image is
built rather than merged:

- The save notice was reworded from "保存不会立即领取" to "保存不会立即执行"
  because claiming a reward is not what opting into the turn does, but
  automation.test.tsx kept asserting the old wording and went red.
- web/src/api.ts and web/src/pages/Credentials.tsx were never run through
  oxfmt, so the Dockerfile's `vp check` gate failed and blocked the build.

The switch itself also had no coverage. The fixture now carries the daily-turn
fields, and the test asserts the domestic account's switch is shown but
disabled while the international one toggles, persists, and issues no POST.
…f the call

Two reviewer notes on app/control_store.py:

- _day checked shape and digits only, so 2026-99-99 and 2026-02-31 were accepted. A
  day that never happened would become a distinct daily-chats key, and the
  once-per-day guard could then be satisfied against a day that is not the real
  one. It now also parses with datetime.date.fromisoformat.

- transition_daily_chat rebuilt its transition dict and its IN (...) fragment on
  every call, concatenating the fragment into the statement text. That was never
  an injection surface -- the interpolated characters are only "?" placeholders,
  and the phase names are bound as parameters -- but the composition is now done
  once at import time from literals, so the method interpolates nothing.

test_day_must_be_a_plain_date grows the calendar-invalid cases, and was verified
to fail on '2026-99-99' before the fix.
@ytzzjx

ytzzjx commented Oct 4, 2026

Copy link
Copy Markdown
Author

Adds two follow-up fixes found while deploying this feature. Both only surface at image-build time rather than in CI on the feature branch.

fix: make the frontend gate pass again after the daily-turn turn

f510dc6 reworded the save notice from 「保存不会立即领取」 to 「保存不会立即执行」 — opting into the daily turn is not claiming a reward, so the rewording is correct — but web/src/automation.test.tsx kept asserting the old text and went red. The same commit also left web/src/api.ts and web/src/pages/Credentials.tsx unformatted for oxfmt. Since Dockerfile runs vp check && vp test run && vp build, that single red test made every production image build from this branch fail.

The assertion is updated, the fixture carries the daily-turn fields, and the switch now has coverage: the domestic account's switch is rendered but disabled (the turn is international-only), while the international one toggles, persists, and issues no POST.

fix: reject impossible day keys, and hoist the transition table out of the call

ControlStore._day validated shape and digits only, so 2026-99-99 and 2026-02-31 were accepted. Such a value becomes a distinct daily_chats row key, and the once-per-day guard is keyed on (account_key, day) — so a day that never happened could satisfy, or be mistaken for, the real one. It now also parses with datetime.date.fromisoformat.

The same commit hoists the transition_daily_chat phase dict and its IN (…) fragment to class constants. That composition was never an injection surface — the interpolated characters are only ? placeholders and every phase name is a bound parameter — but rebuilding it per call is needless work, and it reads as SQL injection to SAST scanners.

Verification

compileall clean. Full suite: 1434 passed / 11 failed, the same 11 pre-existing test_sqlite_state.py / test_terminal_output.py PTY failures that occur on unmodified main in a container with no controlling terminal. tests/test_daily_chat.py all pass, and the new day-validation cases were confirmed to fail (ValueError not raised : '2026-99-99') before the fix.

@sourcery-ai sourcery-ai 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.

New security issues found

Comment thread app/control_store.py
@maiphucgiang
maiphucgiang merged commit afb081d into maiphucgiang:main Oct 4, 2026
8 checks passed
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