mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 19:43:27 +02:00
* docs: delete completed plan docs and fix stale repo documentation (MUL-5698) Ten repo docs were verifiably out of date against the code. Completed plans are deleted outright rather than archived — they are dead weight in every agent's context and their decisions already live in the code. Deleted (all describe work that has shipped): - docs/agent-quick-create-plan.md — marked "未动工", but agenttmpl templates (Phase 1) and the AI-create-agent page (Phase 3) are live - docs/docs-outline.md — tracker planning "Chinese only, 25 pages"; the doc site is 39 pages x 4 languages - docs/docs-rewrite-plan.md — plans 55 mdx, lists webhook autopilot triggers as unrouted; both superseded by what shipped - docs/docs-onboarding-optimization-plan.md — work log for #5714, which landed the four languages and screenshots it lists as pending - docs/onboarding-refactor-plan.md — v3 shipped (welcome-store, welcome-after-onboarding, onboarding_shim) - apps/mobile/docs/project-v1-{plan,gap-audit}.md — marked "pre-implementation"; the picker files they scope no longer exist - docs/plans/* + docs/ideation/* — implementation plans for agent access-scope (agent.visibility) and issue-table server query (/api/issues/grouped), both shipped Fixed: - docs/codex-sandbox-troubleshooting.md — the decision matrix claimed non-darwin gets workspace-write. Linux is danger-full-access (MUL-5578) and Windows is danger-full-access (MUL-4957); Windows had no row at all - apps/mobile/docs/rnr-migration.md — Phase 1 is complete, not "not started"; every checklist item is in the tree - AGENTS.md — architecture list was missing apps/mobile, apps/docs, packages/eslint-config - docs/ui-consistency-audit.md — §3.2 status column is a mid-PR snapshot; #5263 and #5258 have merged - server/internal/agenttmpl/loader.go — drop reference to a deleted doc Co-authored-by: multica-agent <github@multica.ai> * docs: reduce docs/ to design + product-overview, English by default (MUL-5698) docs/ now holds two documents, each with an English default and a .zh.md translation. Everything else was engineering scratch that agents load as context on every run without ever being read by a human. Deleted: - docs/analytics.md, docs/feature-flags.md, docs/timezone-architecture-rfc.md, docs/codex-sandbox-troubleshooting.md, docs/codex-usage-cache-backfill.md, docs/custom-runtimes.md, docs/ui-consistency-audit.md Language convention — English is the default filename, translations carry a language suffix: - docs/design.md (new English) + docs/design.zh.md (was design.md) - docs/product-overview.md (new English) + docs/product-overview.zh.md (was product-overview.md) While translating product-overview, three facts were corrected against the code rather than carried over from the 2026-04-21 survey: the provider list now matches README, onboarding is the shipped three-step about_you/workspace/runtime sequence with Helper creation moved after exit (packages/core/onboarding/step-order.ts), and the stale "28 tables" total was dropped. Reference cleanup so no comment points at a deleted file — .env.example, server/internal/analytics/{client,events}.go, packages/core/analytics/index.ts, server/cmd/server/main.go, server/pkg/featureflag/doc.go, server/cmd/backfill_task_usage_hourly/main.go, and the doc pointers in migrations 100/101/103/104. Migration edits are comment-only; the runner tracks applied versions by filename, not by checksum. docs/assets/ is kept — README.md and README.zh-CN.md embed those images. Co-authored-by: multica-agent <github@multica.ai> * docs: drop product-overview, fix rnr body and CLAUDE/AGENTS drift (MUL-5698) Addresses all four blockers from review. 1+2. Delete docs/product-overview.md and docs/product-overview.zh.md. The review found the doc carried facts that would make an agent do the wrong thing — skill injection claimed a .agent_context/skills fallback for providers that now have native paths in execenv/context.go, and it documented `multica skill create --title` when the CLI only registers --name. Rather than chase those, the document goes: it is derived from code and can be regenerated from code when it is actually wanted. That also removes the zh-as-historical-snapshot problem, since neither language survives. docs/ is now design.md + design.zh.md + assets/. design.zh.md is a faithful translation of the English, not a snapshot, so blocker 1 does not apply to it. 3. apps/mobile/docs/rnr-migration.md: the body contradicted its own status line. §1 asserted in present tense that there is no theming infrastructure, hardcoded tailwind hex, and a three-line global.css; §5.2 said the same. Both are now marked as the pre-Phase-1 baseline with the shipped state noted, §6's Phase 0/1 checklists are backfilled as complete, and Phase 2 is labelled not started. 4. CLAUDE.md gains apps/docs/ and packages/eslint-config/, so the authoritative Project Shape list matches the pointer list in AGENTS.md. The two lists are now identical. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
220 lines
8.4 KiB
Go
220 lines
8.4 KiB
Go
// Backfill_task_usage_hourly seeds the unified hourly rollup table
|
||
// (`task_usage_hourly`) from historical `task_usage`
|
||
// rows. Run once after the hourly-pipeline migrations ship, BEFORE registering
|
||
// the pg_cron job for rollup_task_usage_hourly().
|
||
//
|
||
// SELF-HOST UPGRADE ORDER — migrations 100–104 are one group, but they
|
||
// must NOT be applied in a single `make migrate-up`:
|
||
//
|
||
// 1. Apply 101+102 (creates task_usage_hourly + installs the triggers).
|
||
// 2. Run THIS backfill to seed historical buckets.
|
||
// 3. Apply 103+104 (drops the legacy daily rollups + runtime.timezone)
|
||
// and register the pg_cron job.
|
||
//
|
||
// If you run `migrate-up` straight through to 103/104 before this
|
||
// backfill, the legacy daily pipelines are gone while task_usage_hourly
|
||
// only holds buckets the triggers wrote since 102 — dashboards will show
|
||
// empty history until backfill + cron catch up (tens to hundreds of
|
||
// ticks on a DB with years of data, given the per-tick 1-day cap).
|
||
//
|
||
// Mirrors backfill_task_usage_dashboard_daily: walk task_usage's time
|
||
// range in monthly slices and call the same idempotent window
|
||
// primitive the cron path uses. Then stamp the rollup-state watermark
|
||
// so the first cron tick after backfill does not reprocess history.
|
||
//
|
||
// Re-running is safe — rollup_task_usage_hourly_window is idempotent
|
||
// (recomputes each dirty key from raw and REPLACES the bucket), so a
|
||
// partially completed backfill can be resumed without TRUNCATEing
|
||
// task_usage_hourly first.
|
||
//
|
||
// Read pressure: each slice scans task_usage / agent_task_queue / agent
|
||
// / issue. On a database with years of history that is sustained heavy
|
||
// load. Use --sleep-between-slices to throttle on a busy production DB,
|
||
// and coordinate a maintenance window with the DB team before a
|
||
// full-history run.
|
||
//
|
||
// Operator note: this command does NOT call prune_task_usage_hourly_dirty.
|
||
// The dirty queue starts empty during backfill (triggers fire only on
|
||
// future writes), so there is nothing to prune until the rollup worker
|
||
// has been running for a while.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"os/signal"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"github.com/multica-ai/multica/server/internal/logger"
|
||
)
|
||
|
||
func main() {
|
||
logger.Init()
|
||
if err := run(); err != nil {
|
||
slog.Error("backfill failed", "error", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
var (
|
||
dryRun = flag.Bool("dry-run", false, "log slices that would be processed without touching task_usage_hourly")
|
||
monthsBack = flag.Int("months-back", 0, "limit backfill to the last N months (0 = all available history)")
|
||
forcePartial = flag.Bool("force-partial", false, "acknowledge that --months-back permanently abandons buckets older than the cutoff (the watermark still advances past them)")
|
||
sleep = flag.Duration("sleep-between-slices", 0, "pause this long between monthly slices to throttle source-table read pressure on a busy DB (e.g. 2s)")
|
||
)
|
||
flag.Parse()
|
||
|
||
dbURL := os.Getenv("DATABASE_URL")
|
||
if dbURL == "" {
|
||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||
}
|
||
|
||
// SIGINT/SIGTERM cancels ctx so an in-flight slice stops cleanly —
|
||
// each slice runs in its own transaction (the window function), so
|
||
// Postgres rolls back the interrupted one and the idempotent design
|
||
// lets a later run resume from where this one stopped.
|
||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
pool, err := pgxpool.New(ctx, dbURL)
|
||
if err != nil {
|
||
return fmt.Errorf("connect to database: %w", err)
|
||
}
|
||
defer pool.Close()
|
||
|
||
if err := pool.Ping(ctx); err != nil {
|
||
return fmt.Errorf("ping database: %w", err)
|
||
}
|
||
|
||
// Serialise against the cron rollup and any other backfill run via
|
||
// advisory lock 4246 — the same id the cron entry checks with
|
||
// pg_try_advisory_lock. While this backfill holds it, the cron tick
|
||
// no-ops instead of racing on task_usage_hourly row locks; a second
|
||
// concurrent backfill blocks here until this one finishes. The lock
|
||
// is held on a dedicated session connection for the whole run.
|
||
lockConn, err := pool.Acquire(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("acquire advisory-lock connection: %w", err)
|
||
}
|
||
defer lockConn.Release()
|
||
if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock(4246)`); err != nil {
|
||
return fmt.Errorf("acquire advisory lock 4246: %w", err)
|
||
}
|
||
defer func() {
|
||
// Unlock on a fresh context so a cancelled ctx (SIGINT) does not
|
||
// skip the release. Releasing the connection afterwards would end
|
||
// the session anyway, but an explicit unlock frees it immediately.
|
||
_, _ = lockConn.Exec(context.Background(), `SELECT pg_advisory_unlock(4246)`)
|
||
}()
|
||
|
||
var minTS, maxTS pgtype.Timestamptz
|
||
if err := pool.QueryRow(ctx, `SELECT MIN(created_at), MAX(created_at) FROM task_usage`).Scan(&minTS, &maxTS); err != nil {
|
||
return fmt.Errorf("scan task_usage time range: %w", err)
|
||
}
|
||
if !minTS.Valid {
|
||
slog.Info("task_usage is empty; nothing to backfill")
|
||
if *dryRun {
|
||
return nil
|
||
}
|
||
return stampWatermark(ctx, pool)
|
||
}
|
||
|
||
from := monthFloor(minTS.Time.UTC())
|
||
end := monthFloor(maxTS.Time.UTC()).AddDate(0, 1, 0)
|
||
|
||
if *monthsBack > 0 {
|
||
cutoff := monthFloor(time.Now().UTC()).AddDate(0, -(*monthsBack), 0)
|
||
// A partial backfill still stamps the watermark at now()-5min, so
|
||
// buckets older than the cutoff are abandoned permanently: the cron
|
||
// worker will never look back that far. That data loss must be an
|
||
// explicit operator decision — require --force-partial to proceed.
|
||
if cutoff.After(from) {
|
||
if !*forcePartial {
|
||
return fmt.Errorf("--months-back=%d would skip buckets before %s (oldest available %s) and the watermark would still advance past them; re-run with --force-partial to accept this, or omit --months-back for a full backfill",
|
||
*monthsBack, cutoff.Format(time.RFC3339), minTS.Time.UTC().Format(time.RFC3339))
|
||
}
|
||
from = cutoff
|
||
slog.Warn("partial backfill: --months-back limits coverage; older buckets will be left empty and the watermark will still advance past them",
|
||
"months_back", *monthsBack, "effective_from", from.Format(time.RFC3339),
|
||
"oldest_available", minTS.Time.UTC().Format(time.RFC3339))
|
||
}
|
||
}
|
||
|
||
slog.Info("backfill range", "from", from.Format(time.RFC3339), "to", end.Format(time.RFC3339), "dry_run", *dryRun, "sleep_between_slices", sleep.String())
|
||
|
||
cursor := from
|
||
var totalRows int64
|
||
for cursor.Before(end) {
|
||
next := cursor.AddDate(0, 1, 0)
|
||
if *dryRun {
|
||
slog.Info("would roll up slice", "from", cursor.Format(time.RFC3339), "to", next.Format(time.RFC3339))
|
||
cursor = next
|
||
continue
|
||
}
|
||
var rows int64
|
||
err := pool.QueryRow(
|
||
ctx,
|
||
`SELECT rollup_task_usage_hourly_window($1::timestamptz, $2::timestamptz)`,
|
||
cursor, next,
|
||
).Scan(&rows)
|
||
if err != nil {
|
||
return fmt.Errorf("rollup slice %s..%s: %w", cursor.Format(time.RFC3339), next.Format(time.RFC3339), err)
|
||
}
|
||
totalRows += rows
|
||
slog.Info("rolled up slice", "from", cursor.Format(time.RFC3339), "to", next.Format(time.RFC3339), "rows_touched", rows)
|
||
cursor = next
|
||
if *sleep > 0 && cursor.Before(end) {
|
||
select {
|
||
case <-time.After(*sleep):
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
}
|
||
|
||
if *dryRun {
|
||
slog.Info("dry-run complete; watermark left untouched")
|
||
return nil
|
||
}
|
||
// Stamp on a fresh context so a SIGINT arriving after the slices
|
||
// complete cannot skip the watermark UPDATE — losing it would force the
|
||
// next run to restart from epoch. Mirrors the deferred advisory-unlock.
|
||
if err := stampWatermark(context.Background(), pool); err != nil {
|
||
return err
|
||
}
|
||
slog.Info("backfill complete", "total_rows_touched", totalRows)
|
||
return nil
|
||
}
|
||
|
||
// stampWatermark moves the hourly rollup state's watermark to
|
||
// `now() - 5 min`, mirroring the cron entry's upper bound. The next
|
||
// scheduled tick therefore picks up only events newer than the
|
||
// backfill horizon and does not redo work the backfill already did.
|
||
func stampWatermark(ctx context.Context, pool *pgxpool.Pool) error {
|
||
tag, err := pool.Exec(ctx, `
|
||
UPDATE task_usage_hourly_rollup_state
|
||
SET watermark_at = now() - INTERVAL '5 minutes'
|
||
WHERE id = 1
|
||
`)
|
||
if err != nil {
|
||
return fmt.Errorf("stamp watermark: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
slog.Warn("no rollup state row to stamp; was the task_usage_hourly schema migration applied?")
|
||
return nil
|
||
}
|
||
fmt.Println("watermark stamped to now() - 5 minutes")
|
||
return nil
|
||
}
|
||
|
||
func monthFloor(t time.Time) time.Time {
|
||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||
}
|