Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c182a38
Add scoped agent research memory and ERP reads
michaelmwu Jul 28, 2026
013c16f
Reuse Discord server ID for agent guild binding
michaelmwu Jul 28, 2026
737dd54
Use Discord server ID for agent guild binding
michaelmwu Jul 28, 2026
9e90db3
Add Discord role diagnostics panel
michaelmwu Jul 28, 2026
3a4901f
Reuse API secret for agent routes
michaelmwu Jul 28, 2026
8375bca
Add durable agent schedules
michaelmwu Jul 28, 2026
91e99d2
Harden agent memory boundaries
michaelmwu Jul 28, 2026
6890a6f
Add generic agent schedule loop
michaelmwu Jul 28, 2026
2d829f3
Add agent schedule creation flow
michaelmwu Jul 28, 2026
05a6d44
Merge main and harden agent schedules
michaelmwu Jul 28, 2026
047edd3
Harden agent privacy and planner fallbacks
michaelmwu Jul 28, 2026
569d507
Make scheduled agent jobs idempotent
michaelmwu Jul 28, 2026
1bca150
Format queue claim regression test
michaelmwu Jul 28, 2026
fe5639d
Harden agent schedule validation
michaelmwu Aug 4, 2026
cc265c2
Clarify recurring schedule run state
michaelmwu Aug 4, 2026
b31b7cb
Bound scheduled agent response handling
michaelmwu Aug 4, 2026
65b06ce
Fix stale durable job lease fencing
michaelmwu Aug 4, 2026
f4cb350
Harden scheduled GitHub reports
michaelmwu Aug 4, 2026
e7eecf5
fix: harden durable memory access
michaelmwu Aug 4, 2026
6e28f9a
fix: enforce scheduled tool deadlines
michaelmwu Aug 4, 2026
f324947
Fix scheduled report delivery recovery
michaelmwu Aug 4, 2026
eeca581
Fix live agent policy configuration
michaelmwu Aug 4, 2026
d90773d
test: update CRM deadline fakes
michaelmwu Aug 4, 2026
2f64b28
fix: harden scheduled CRM and ERP reads
michaelmwu Aug 4, 2026
959f6ee
fix: gate scheduled web extraction and reversible memory limit
michaelmwu Aug 4, 2026
38a5419
Fix scheduled run execution fencing
michaelmwu Aug 4, 2026
31f4647
Fix durable job lease recovery
michaelmwu Aug 4, 2026
c845b0f
fix: bound worker execution to job leases
michaelmwu Aug 4, 2026
58a39c7
fix: gate default schedules by integration config
michaelmwu Aug 4, 2026
f573a6f
fix: harden scheduled agent execution
michaelmwu Aug 5, 2026
5f13b00
fix: gate default scheduled agent tools
michaelmwu Aug 6, 2026
7c4d34b
fix: validate agent schedule runtime bounds
michaelmwu Aug 8, 2026
a2f2f60
fix: harden agent review findings
michaelmwu Aug 9, 2026
7073be0
fix: recover queued schedule jobs
michaelmwu Aug 12, 2026
043308f
fix: harden scheduled agent execution safeguards
michaelmwu Aug 13, 2026
ad9228b
fix: bound scheduled execution and thread permissions
michaelmwu Aug 14, 2026
bd7ff0a
fix: preserve bounded schedule execution outcomes
michaelmwu Aug 14, 2026
85cb884
fix: persist agent schedule confirmations
michaelmwu Aug 15, 2026
accef11
merge: sync main and harden agent schedules
michaelmwu Aug 16, 2026
0cebb57
test: disambiguate dashboard Discord link
michaelmwu Aug 16, 2026
98fa086
fix: isolate corrupt due schedules
michaelmwu Aug 16, 2026
74be5b8
fix: harden agent confirmation flows
michaelmwu Aug 17, 2026
fe67fcb
test: cover pending plan cleanup startup
michaelmwu Aug 17, 2026
08475fb
fix: harden agent planner boundaries
michaelmwu Aug 18, 2026
8f68e29
fix: retain agent follow-up audit metadata
michaelmwu Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,22 @@ WEB_HOST_BIND=127.0.0.1
# `WEB_PORT` with a deterministic per-worktree port automatically.
# Deprecated fallback names still work: WEBHOOK_INGEST_HOST, WEBHOOK_INGEST_PORT,
# WEBHOOK_INGEST_HOST_BIND, WEBHOOK_INGEST_HOST_PORT.
# Required for protected non-dashboard API routes. Webhook routes use this only
# when WEBHOOK_SHARED_SECRET is unset.
# Required for protected non-dashboard API routes, including /agent/*. Webhook
# routes use this only when WEBHOOK_SHARED_SECRET is unset.
API_SHARED_SECRET=
# Production agent RBAC uses the existing single DISCORD_SERVER_ID plus
# immutable Discord role IDs (not role names). Comma-separate role IDs if a
# bundle intentionally has more than one role. A combined Billing / ERP Dev
# role ID must appear in both applicable bundle variables. Never use the guild
# ID: Discord assigns it to the @everyone role, which would grant every member.
AGENT_DISCORD_ADMIN_ROLE_IDS=
AGENT_DISCORD_STEERING_COMMITTEE_ROLE_IDS=
AGENT_DISCORD_BILLING_ROLE_IDS=
AGENT_DISCORD_ERP_DEVELOPER_ROLE_IDS=
AGENT_DISCORD_PROJECT_MANAGER_ROLE_IDS=
AGENT_DISCORD_ENGINEER_ROLE_IDS=
# Local/test migration aid only. Never effective outside local/dev/test.
AGENT_ALLOW_ROLE_NAME_FALLBACK=false
# Env-only key used to encrypt admin-dashboard-managed secret config values.
CONFIG_SECRET_KEY=
# Optional: separate secret for external /webhooks/* callers. When unset or
Expand Down Expand Up @@ -103,6 +116,10 @@ OUTLINE_CONTENTS_API_KEY=
ERPNEXT_BASE_URL=https://erp.example.com/
ERPNEXT_API_TIMEOUT_SECONDS=20.0
ERPNEXT_API_KEY=
# Required before the agent exposes Billing/ERP read tools. Set this to the
# single Discord organization/guild ID that is authorized to use these ERPNext
# credentials; agent ERP reads fail closed when it is unset or does not match.
AGENT_ERP_ORGANIZATION_ID=
# Optional: Discord logs webhook for operator-visible logs from commands/jobs
DISCORD_LOGS_WEBHOOK_URL=
# Optional: wait for Discord server confirmation before returning from webhook call
Expand Down Expand Up @@ -182,6 +199,27 @@ AGENT_STRUCTURED_PLANNER_ENABLED=true
AGENT_STRUCTURED_PLANNER_TIMEOUT_SECONDS=6.0
AGENT_INTENT_NORMALIZER_ENABLED=true
AGENT_INTENT_NORMALIZER_TIMEOUT_SECONDS=3.0
# Maximum public-web tool/planner turns in one request. Writes never run in this loop.
AGENT_PLANNING_MAX_STEPS=3
# Caller-visible budget for synchronous agent planning/read requests. This is
# intentionally below AGENT_API_TIMEOUT_SECONDS; a timed-out sync worker may
# still be unwinding an upstream DNS or HTTP call in the background.
AGENT_REQUEST_RESPONSE_BUDGET_SECONDS=55.0
# Best-effort public-web operation budget. It must not exceed the response
# budget; the API response budget is the hard Discord-facing bound.
AGENT_PUBLIC_WEB_DEADLINE_SECONDS=50.0
# Sweep expired durable agent-memory facts even when no user sends a request.
# The cleanup worker only returns a deletion count and never exposes fact data.
AGENT_MEMORY_CLEANUP_ENABLED=true
AGENT_MEMORY_CLEANUP_INTERVAL_SECONDS=86400
# Durable recurring agent reports use five-field cron expressions and persist
# a fixed read-only capability catalog. The API dispatcher is safe to enable
# with no schedules configured. Five minutes is the minimum allowed cadence.
AGENT_SCHEDULE_ENABLED=true
AGENT_SCHEDULE_DISPATCH_INTERVAL_SECONDS=30
AGENT_SCHEDULE_DISPATCH_BATCH_SIZE=25
AGENT_SCHEDULE_MIN_INTERVAL_SECONDS=300
AGENT_SCHEDULE_EXECUTION_TIMEOUT_SECONDS=120.0
AGENT_FAST_MODEL=
AGENT_FAST_BASE_URL=
AGENT_FAST_API_KEY=
Expand All @@ -197,13 +235,12 @@ JOB_LEAD_CLASSIFIER_ENABLED=true
JOB_LEAD_CLASSIFIER_MODEL=
JOB_LEAD_CLASSIFIER_TIMEOUT_SECONDS=8.0
# Optional deterministic agent tool integrations.
# GitHub Issues are the canonical todo backend. Members can read and write the
# default repo; Steering Committee/Admin/Owner can work across every repository
# selected for the GitHub App installation and the organization's Projects.
# GitHub Issues are the canonical todo backend. Regular Members receive no
# agent access; Engineering uses the legacy configured-repository path, while
# Steering Committee/Admin/Owner can work across selected GitHub App repos and
# the organization's Projects.
GITHUB_DEFAULT_REPO=508-dev/todos
GITHUB_ORGANIZATION=508-dev
# Optional additional repos that every Discord Member may work in.
GITHUB_MEMBER_EXTRA_REPOS=
# Keep true for Steering Committee/Admin/Owner access to every App-selected repo.
GITHUB_STEERING_ALL_INSTALLED_REPOS=true
# Used only when the preceding setting is false.
Expand All @@ -222,6 +259,22 @@ GITHUB_APP_PRIVATE_KEY=
# Temporary legacy fallback while moving to the GitHub App.
GITHUB_API_TOKEN=
GITHUB_ALLOWED_REPOS=
# Optional public-web research. Providers are tried in this fallback order only
# when configured. Set one or more of SearXNG, Brave, and Firecrawl credentials.
AGENT_WEB_SEARCH_PROVIDER_ORDER=searxng,brave,firecrawl
AGENT_WEB_SEARCH_TIMEOUT_SECONDS=5.0
AGENT_WEB_DEFAULT_RESULT_LIMIT=5
# SearXNG may be a private in-cluster HTTP endpoint, for example http://searxng:8080.
# Its SearXNG settings must enable the JSON search response format.
SEARXNG_BASE_URL=
SEARXNG_SEARCH_LANGUAGE=
# Brave and Firecrawl endpoints must be HTTPS; their API keys remain backend-only.
BRAVE_SEARCH_API_KEY=
BRAVE_SEARCH_BASE_URL=https://api.search.brave.com
BRAVE_SEARCH_COUNTRY=
BRAVE_SEARCH_LANGUAGE=
FIRECRAWL_API_KEY=
FIRECRAWL_BASE_URL=https://api.firecrawl.dev
# Resume extraction can use a different provider from agent planning.
# Leave RESUME_AI_API_KEY/BASE_URL unset to use OPENAI_API_KEY/OPENAI_BASE_URL.
# Model names without provider prefix are OpenRouter-prefixed to openai/<model>.
Expand Down Expand Up @@ -252,9 +305,17 @@ DISCORD_DEFAULT_JOB_FORUM_CHANNELS=gigs:part_time,fulltime-roles:full_time
# export HEALTHCHECK_PORT in your shell.
HEALTHCHECK_PORT=3000
BACKEND_API_BASE_URL=http://127.0.0.1:8090
AGENT_API_TIMEOUT_SECONDS=8.0
# The bot defers Discord interactions; allow the bounded web-research loop to
# complete provider fallback and planning turns.
AGENT_API_TIMEOUT_SECONDS=60.0
AUDIT_API_BASE_URL=
AUDIT_API_TIMEOUT_SECONDS=2.0
# Worker-to-API handoff for durable recurring agent runs. Host-run development
# uses the same backend address as the Discord bot; compose overrides this.
# This must cover schedule execution plus 60 seconds for role refresh, summary,
# and Discord delivery, and fit inside the durable worker job lease.
AGENT_SCHEDULE_API_BASE_URL=http://127.0.0.1:8090
AGENT_SCHEDULE_API_TIMEOUT_SECONDS=360.0
# Worker mailbox resume intake (required if email intake is enabled)
CHECK_EMAIL_WAIT=2
EMAIL_USERNAME=your_email@example.com
Expand Down
4 changes: 3 additions & 1 deletion ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Pydantic import errors.
- `Optional`: `REDIS_KEY_PREFIX` (default: `jobs`)
- `Optional`: `REDIS_HOST_BIND` (default: `127.0.0.1`)
- `Optional`: `REDIS_HOST_PORT` (default when unset: `CONDUCTOR_PORT + 0` inside Conductor, otherwise computed per worktree as `12000 + WORKTREE_ENV_SLOT`; use `6379` only if explicitly pinned via env/.env; see `./scripts/docker-compose.sh print-ports`)
- `Optional`: `JOB_TIMEOUT_SECONDS` (default: `600`)
- `Optional`: `JOB_TIMEOUT_SECONDS` (default: `600`, minimum: `6`; the worker
reserves five seconds before the durable lease expires for cancellation and
recovery)
- `Optional`: `JOB_RESULT_TTL_SECONDS` (default: `3600`)
- `Optional`: `JOB_MAX_ATTEMPTS` (default: `8`)
- `Optional`: `JOB_RETRY_BASE_SECONDS` (default: `5`)
Expand Down
17 changes: 17 additions & 0 deletions apps/admin_dashboard/src/agent-schedule-run-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"

import { agentScheduleRunToastMessage } from "./agent-schedule-run-status"

describe("agentScheduleRunToastMessage", () => {
it("distinguishes newly queued work from coalesced requests", () => {
expect(agentScheduleRunToastMessage({ status: "queued" })).toBe(
"Queued recurring agent schedule run",
)
expect(agentScheduleRunToastMessage({ status: "already_queued" })).toBe(
"Recurring agent schedule run is already queued",
)
expect(agentScheduleRunToastMessage({ status: "already_requested" })).toBe(
"A recent recurring agent schedule run already exists",
)
})
})
18 changes: 18 additions & 0 deletions apps/admin_dashboard/src/agent-schedule-run-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type AgentScheduleRunResponse = {
status?: string
job_id?: string | null
dispatch_pending?: boolean
run?: {
id?: string
status?: string
job_id?: string | null
}
}

export function agentScheduleRunToastMessage(response: AgentScheduleRunResponse) {
if (response.status === "queued") return "Queued recurring agent schedule run"
if (response.status === "already_queued") return "Recurring agent schedule run is already queued"
if (response.status === "already_requested")
return "A recent recurring agent schedule run already exists"
return "Recurring agent schedule run request accepted"
}
94 changes: 94 additions & 0 deletions apps/admin_dashboard/src/agent-schedules-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import {
type AgentSchedule,
type AgentScheduleRun,
AgentSchedulesView,
} from "./views/agent-schedules-view"

afterEach(cleanup)

const schedule: AgentSchedule = {
id: "e5e5e5e5-0000-4000-8000-000000000001",
guild_id: "123",
owner_discord_user_id: "456",
name: "Weekly GitHub triage",
cron_expression: "0 9 * * 1",
timezone: "Asia/Tokyo",
status: "active",
next_run_at: "2026-08-03T00:00:00Z",
last_run_at: null,
definition: {
prompt: "Inspect onboarding and ERP health.",
execution_mode: "agent_loop",
summary_mode: "deterministic",
sources_are_public: false,
tool_allowlist: ["onboarding_read.get_summary", "erp_read.search_projects"],
delivery: { channel_id: "789" },
actions: [],
},
}

const staleDelivery: AgentScheduleRun = {
id: "e5e5e5e5-0000-4000-8000-000000000002",
schedule_id: schedule.id,
status: "running",
delivery_status: "claimed",
delivery_claimed_at: "2026-08-03T00:00:00Z",
}

describe("AgentSchedulesView", () => {
it("renders the frozen envelope and sends lifecycle controls", () => {
const onControl = vi.fn()
render(
<AgentSchedulesView
schedules={[schedule]}
deliveryAttention={[]}
schedulerEnabled
loading={{}}
canWrite
canCreate
onRefresh={vi.fn()}
onCreate={vi.fn().mockResolvedValue(true)}
onControl={onControl}
onRun={vi.fn()}
onResolveDelivery={vi.fn()}
/>,
)

expect(screen.getByText("Weekly GitHub triage")).toBeVisible()
expect(screen.getByText("Bounded agent loop · 2 read-only tools")).toBeVisible()
expect(screen.getByText(/fixed catalog of read-only tools/i)).toBeVisible()
expect(screen.getByText(/CRM, ERP, billing, onboarding, and public-web/i)).toBeVisible()
expect(screen.getByText("/schedule-github-issues")).toBeVisible()

fireEvent.click(screen.getByRole("button", { name: "Pause" }))

expect(onControl).toHaveBeenCalledWith(schedule.id, "pause")
})

it("makes stale claims visible and lets an operator mark them unknown", () => {
const onResolveDelivery = vi.fn()
render(
<AgentSchedulesView
schedules={[schedule]}
deliveryAttention={[staleDelivery]}
schedulerEnabled
loading={{}}
canWrite
canCreate
onRefresh={vi.fn()}
onCreate={vi.fn().mockResolvedValue(true)}
onControl={vi.fn()}
onRun={vi.fn()}
onResolveDelivery={onResolveDelivery}
/>,
)

expect(screen.getByText("Delivery attention required")).toBeVisible()
fireEvent.click(screen.getByRole("button", { name: "Mark outcome unknown" }))

expect(onResolveDelivery).toHaveBeenCalledWith(staleDelivery.id)
})
})
98 changes: 98 additions & 0 deletions apps/admin_dashboard/src/discord-diagnostics-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import {
type DiscordDiagnosticsResponse,
DiscordDiagnosticsView,
} from "./views/discord-diagnostics-view"

afterEach(cleanup)

const diagnostics: DiscordDiagnosticsResponse = {
guild: {
id: "123",
name: "508.dev",
configured_server_matches: true,
},
snapshot: {
created_at: "2026-07-28T00:00:00Z",
source: "discord_api",
},
bot: {
manage_roles: true,
top_role: { id: "900", name: "Bot", position: 10 },
},
agent: {
configured_role_count: 2,
resolved_role_count: 1,
missing_role_count: 1,
unconfigured_binding_count: 5,
api_shared_secret_status: "configured",
role_bindings: [
{
bundle: "admin",
label: "Admin",
environment_variable: "AGENT_DISCORD_ADMIN_ROLE_IDS",
role_ids: ["456"],
roles: [
{
id: "456",
name: "Admin",
status: "resolved",
managed: false,
manageable_by_bot: true,
},
],
status: "resolved",
},
{
bundle: "billing",
label: "Billing",
environment_variable: "AGENT_DISCORD_BILLING_ROLE_IDS",
role_ids: ["999"],
roles: [{ id: "999", status: "missing" }],
status: "attention",
},
],
},
roles: [
{
id: "456",
name: "Admin",
position: 9,
managed: false,
is_default: false,
manageable_by_bot: true,
},
{
id: "123",
name: "@everyone",
position: 0,
managed: false,
is_default: true,
manageable_by_bot: false,
},
],
}

describe("DiscordDiagnosticsView", () => {
it("shows copyable role IDs, binding health, and filters the role catalog", () => {
render(
<DiscordDiagnosticsView diagnostics={diagnostics} onRefresh={vi.fn()} onNotice={vi.fn()} />,
)

expect(screen.getByText("508.dev")).toBeVisible()
expect(screen.getByText("AGENT_DISCORD_ADMIN_ROLE_IDS")).toBeVisible()
expect(screen.getByRole("table", { name: "Discord server roles" })).toBeVisible()
expect(screen.getAllByText("456").length).toBeGreaterThan(0)
expect(screen.getByText("Secret values are never displayed.")).toBeVisible()

fireEvent.change(screen.getByLabelText("Search roles"), {
target: { value: "everyone" },
})

const catalog = screen.getByRole("table", { name: "Discord server roles" })
expect(within(catalog).getAllByText("@everyone")[0]).toBeVisible()
expect(within(catalog).queryByRole("cell", { name: "Admin" })).not.toBeInTheDocument()
})
})
Loading