Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

411 lines
14 KiB
Go

package workspace
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
const agentDataMaintenanceRowID = "agent-conversations"
const (
maximumManualCompactionDatabaseBytes = int64(64 << 20)
minimumManualCompactionFreeBytes = int64(8 << 20)
manualCompactionTimeout = 3 * time.Second
)
type AgentDataMaintenanceState struct {
LastAutomaticPurgeAtUnixMS int64
}
type AgentSessionResourceCleanup struct {
WorkspaceID string
AgentSessionID string
EnqueuedAtUnixMS int64
AttemptCount int
LastError string
}
func (s *SQLiteStore) applyAgentDataMaintenanceV1(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationAgentDataMaintenanceV1)
if err != nil {
return err
}
if applied {
return nil
}
_, err = s.writeDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS agent_data_maintenance (
id TEXT PRIMARY KEY,
last_automatic_purge_at_unix_ms INTEGER NOT NULL DEFAULT 0,
updated_at_unix_ms INTEGER NOT NULL
);
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES (?, ?);
`, schemaMigrationAgentDataMaintenanceV1, unixMs(time.Now().UTC()))
if err != nil {
return fmt.Errorf("migrate agent data maintenance state: %w", err)
}
return nil
}
func (s *SQLiteStore) applyAgentDataMaintenanceV2(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationAgentDataMaintenanceV2)
if err != nil {
return err
}
if applied {
return nil
}
_, err = s.writeDB.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS agent_session_resource_cleanup_queue (
workspace_id TEXT NOT NULL,
agent_session_id TEXT NOT NULL,
enqueued_at_unix_ms INTEGER NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
updated_at_unix_ms INTEGER NOT NULL,
PRIMARY KEY (workspace_id, agent_session_id)
);
CREATE INDEX IF NOT EXISTS idx_agent_session_resource_cleanup_queue_enqueued
ON agent_session_resource_cleanup_queue(enqueued_at_unix_ms ASC, workspace_id ASC, agent_session_id ASC);
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES (?, ?);
`, schemaMigrationAgentDataMaintenanceV2, unixMs(time.Now().UTC()))
if err != nil {
return fmt.Errorf("migrate agent session resource cleanup queue: %w", err)
}
return nil
}
// applyAgentDataMaintenanceV3 is the original Workspace-scoped cleanup fence.
// V4 replaces it with the global fence required by the current physical path
// layout while preserving the migration history of databases that ran V3.
func (s *SQLiteStore) applyAgentDataMaintenanceV3(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationAgentDataMaintenanceV3)
if err != nil || applied {
return err
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin agent session resource cleanup reuse fence migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
CREATE TRIGGER IF NOT EXISTS trg_workspace_agent_sessions_block_pending_resource_cleanup
BEFORE INSERT ON workspace_agent_sessions
WHEN EXISTS (
SELECT 1
FROM agent_session_resource_cleanup_queue cleanup
WHERE cleanup.workspace_id = NEW.workspace_id
AND cleanup.agent_session_id = NEW.agent_session_id
)
BEGIN
SELECT RAISE(ABORT, 'agent session resources are pending cleanup');
END;
`); err != nil {
return fmt.Errorf("create agent session resource cleanup reuse fence: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES (?, ?)
`, schemaMigrationAgentDataMaintenanceV3, unixMs(time.Now().UTC())); err != nil {
return fmt.Errorf("record agent session resource cleanup reuse fence migration: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit agent session resource cleanup reuse fence migration: %w", err)
}
return nil
}
// applyAgentDataMaintenanceV4 prevents a purged Session ID from being reused
// in any Workspace while its runtime root or copied attachments are still
// queued for deletion. Those physical resources are currently keyed only by
// agent_session_id, so the database fence must be global even though canonical
// Session identity includes workspace_id.
func (s *SQLiteStore) applyAgentDataMaintenanceV4(ctx context.Context) error {
applied, err := s.hasMigration(ctx, schemaMigrationAgentDataMaintenanceV4)
if err != nil || applied {
return err
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin global agent session resource cleanup reuse fence migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
DROP TRIGGER IF EXISTS trg_workspace_agent_sessions_block_pending_resource_cleanup;
CREATE TRIGGER trg_workspace_agent_sessions_block_pending_resource_cleanup
BEFORE INSERT ON workspace_agent_sessions
WHEN EXISTS (
SELECT 1
FROM agent_session_resource_cleanup_queue cleanup
WHERE cleanup.agent_session_id = NEW.agent_session_id
)
BEGIN
SELECT RAISE(ABORT, 'agent session resources are pending cleanup');
END;
`); err != nil {
return fmt.Errorf("create global agent session resource cleanup reuse fence: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES (?, ?)
`, schemaMigrationAgentDataMaintenanceV4, unixMs(time.Now().UTC())); err != nil {
return fmt.Errorf("record global agent session resource cleanup reuse fence migration: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit global agent session resource cleanup reuse fence migration: %w", err)
}
return nil
}
// AgentSessionIDExists reports whether any canonical Session, live or
// tombstoned, in any Workspace currently uses agentSessionID. Runtime roots and
// copied attachments are keyed only by this ID, so cleanup must use a global
// identity check rather than the canonical (workspace, session) key.
func (s *SQLiteStore) AgentSessionIDExists(ctx context.Context, agentSessionID string) (bool, error) {
if s == nil || s.readDB == nil {
return false, errors.New("workspace database is not initialized")
}
agentSessionID = strings.TrimSpace(agentSessionID)
if agentSessionID == "" {
return false, nil
}
var exists bool
if err := s.readDB.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1
FROM workspace_agent_sessions
WHERE agent_session_id = ?
)
`, agentSessionID).Scan(&exists); err != nil {
return false, fmt.Errorf("check global agent session identity: %w", err)
}
return exists, nil
}
// OtherWorkspaceLiveAgentSessionIDExists reports whether a live canonical
// Session outside workspaceID currently shares agentSessionID. The global
// Agent/browser resource releaser is keyed only by Session ID, so recoverable
// deletion must not release it while another Workspace still has a live owner.
func (s *SQLiteStore) OtherWorkspaceLiveAgentSessionIDExists(
ctx context.Context,
workspaceID string,
agentSessionID string,
) (bool, error) {
if s == nil || s.readDB == nil {
return false, errors.New("workspace database is not initialized")
}
workspaceID = strings.TrimSpace(workspaceID)
agentSessionID = strings.TrimSpace(agentSessionID)
if workspaceID == "" || agentSessionID == "" {
return false, nil
}
var exists bool
if err := s.readDB.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1
FROM workspace_agent_sessions
WHERE workspace_id <> ?
AND agent_session_id = ?
AND deleted_at_unix_ms = 0
)
`, workspaceID, agentSessionID).Scan(&exists); err != nil {
return false, fmt.Errorf("check other Workspace live agent session identity: %w", err)
}
return exists, nil
}
func enqueueAgentSessionResourceCleanupTx(
ctx context.Context,
tx *sql.Tx,
items []AgentSessionResourceCleanup,
) error {
if tx == nil {
return errors.New("workspace database transaction is not initialized")
}
if len(items) == 0 {
return nil
}
now := unixMs(time.Now().UTC())
for _, item := range items {
workspaceID := strings.TrimSpace(item.WorkspaceID)
agentSessionID := strings.TrimSpace(item.AgentSessionID)
if workspaceID == "" || agentSessionID == "" {
return errors.New("agent session resource cleanup requires workspace and session")
}
enqueuedAt := item.EnqueuedAtUnixMS
if enqueuedAt <= 0 {
enqueuedAt = now
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO agent_session_resource_cleanup_queue (
workspace_id, agent_session_id, enqueued_at_unix_ms, attempt_count, last_error, updated_at_unix_ms
) VALUES (?, ?, ?, 0, '', ?)
ON CONFLICT(workspace_id, agent_session_id) DO UPDATE SET
updated_at_unix_ms = excluded.updated_at_unix_ms
`, workspaceID, agentSessionID, enqueuedAt, now); err != nil {
return fmt.Errorf("enqueue agent session resource cleanup: %w", err)
}
}
return nil
}
func (s *SQLiteStore) ListAgentSessionResourceCleanup(
ctx context.Context,
limit int,
) ([]AgentSessionResourceCleanup, error) {
if s == nil || s.readDB == nil {
return nil, errors.New("workspace database is not initialized")
}
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := s.readDB.QueryContext(ctx, `
SELECT workspace_id, agent_session_id, enqueued_at_unix_ms, attempt_count, last_error
FROM agent_session_resource_cleanup_queue
ORDER BY enqueued_at_unix_ms ASC, workspace_id ASC, agent_session_id ASC
LIMIT ?
`, limit)
if err != nil {
return nil, fmt.Errorf("list agent session resource cleanup: %w", err)
}
defer rows.Close()
items := make([]AgentSessionResourceCleanup, 0)
for rows.Next() {
var item AgentSessionResourceCleanup
if err := rows.Scan(
&item.WorkspaceID,
&item.AgentSessionID,
&item.EnqueuedAtUnixMS,
&item.AttemptCount,
&item.LastError,
); err != nil {
return nil, fmt.Errorf("scan agent session resource cleanup: %w", err)
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate agent session resource cleanup: %w", err)
}
return items, nil
}
func (s *SQLiteStore) CompleteAgentSessionResourceCleanup(
ctx context.Context,
workspaceID string,
agentSessionID string,
) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
_, err := s.writeDB.ExecContext(ctx, `
DELETE FROM agent_session_resource_cleanup_queue
WHERE workspace_id = ? AND agent_session_id = ?
`, strings.TrimSpace(workspaceID), strings.TrimSpace(agentSessionID))
if err != nil {
return fmt.Errorf("complete agent session resource cleanup: %w", err)
}
return nil
}
func (s *SQLiteStore) FailAgentSessionResourceCleanup(
ctx context.Context,
workspaceID string,
agentSessionID string,
cleanupErr string,
) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
cleanupErr = strings.TrimSpace(cleanupErr)
if len(cleanupErr) > 2000 {
cleanupErr = cleanupErr[:2000]
}
_, err := s.writeDB.ExecContext(ctx, `
UPDATE agent_session_resource_cleanup_queue
SET attempt_count = attempt_count + 1,
last_error = ?,
updated_at_unix_ms = ?
WHERE workspace_id = ? AND agent_session_id = ?
`, cleanupErr, unixMs(time.Now().UTC()), strings.TrimSpace(workspaceID), strings.TrimSpace(agentSessionID))
if err != nil {
return fmt.Errorf("record agent session resource cleanup failure: %w", err)
}
return nil
}
func (s *SQLiteStore) GetAgentDataMaintenanceState(ctx context.Context) (AgentDataMaintenanceState, error) {
if s == nil || s.readDB == nil {
return AgentDataMaintenanceState{}, errors.New("workspace database is not initialized")
}
var state AgentDataMaintenanceState
err := s.readDB.QueryRowContext(ctx, `
SELECT last_automatic_purge_at_unix_ms
FROM agent_data_maintenance
WHERE id = ?
`, agentDataMaintenanceRowID).Scan(&state.LastAutomaticPurgeAtUnixMS)
if errors.Is(err, sql.ErrNoRows) {
return AgentDataMaintenanceState{}, nil
}
if err != nil {
return AgentDataMaintenanceState{}, fmt.Errorf("get agent data maintenance state: %w", err)
}
return state, nil
}
func (s *SQLiteStore) MarkAutomaticAgentDataPurgeCompleted(ctx context.Context, atUnixMS int64) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
_, err := s.writeDB.ExecContext(ctx, `
INSERT INTO agent_data_maintenance (id, last_automatic_purge_at_unix_ms, updated_at_unix_ms)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
last_automatic_purge_at_unix_ms = excluded.last_automatic_purge_at_unix_ms,
updated_at_unix_ms = excluded.updated_at_unix_ms
`, agentDataMaintenanceRowID, atUnixMS, unixMs(time.Now().UTC()))
if err != nil {
return fmt.Errorf("mark automatic agent data purge completed: %w", err)
}
return nil
}
// CompactDeletedDataIfSafe performs a best-effort full-database compaction
// only for small databases with substantial free pages. Callers must already
// have established an explicit, idle maintenance window. The tight size and
// time bounds keep this optional manual step out of automatic maintenance.
func (s *SQLiteStore) CompactDeletedDataIfSafe(ctx context.Context) (bool, error) {
if s == nil || s.writeDB == nil {
return false, errors.New("workspace database is not initialized")
}
var pageCount, freePages, pageSize int64
if err := s.writeDB.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err != nil {
return false, fmt.Errorf("read database page count before compaction: %w", err)
}
if err := s.writeDB.QueryRowContext(ctx, `PRAGMA freelist_count`).Scan(&freePages); err != nil {
return false, fmt.Errorf("read database free pages before compaction: %w", err)
}
if err := s.writeDB.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil {
return false, fmt.Errorf("read database page size before compaction: %w", err)
}
if pageCount <= 0 || pageSize <= 0 || pageCount > maximumManualCompactionDatabaseBytes/pageSize {
return false, nil
}
databaseBytes := pageCount * pageSize
freeBytes := freePages * pageSize
if freeBytes < minimumManualCompactionFreeBytes || freeBytes*4 < databaseBytes {
return false, nil
}
compactCtx, cancel := context.WithTimeout(ctx, manualCompactionTimeout)
defer cancel()
if _, err := s.writeDB.ExecContext(compactCtx, `VACUUM`); err != nil {
return false, fmt.Errorf("compact deleted database pages: %w", err)
}
if _, err := s.writeDB.ExecContext(compactCtx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
return false, fmt.Errorf("checkpoint compacted database: %w", err)
}
return true, nil
}