refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
This commit is contained in:
1 parent
61fb4b947f
commit
ff5e22092a
45 files changed
+3409
-300
No files matched your search
@@ -96,10 +96,19 @@ export function buildRunMethods(
|
||||
sessionDisplayId: string | null;
|
||||
unattributedCost: boolean;
|
||||
}) {
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId }
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId, started_at as startedAt from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId; startedAt: string | null }
|
||||
| undefined;
|
||||
if (!run) return { budgetHardStopped: false };
|
||||
// A run that never transitioned to 'running' (startedAt === null) and
|
||||
// is now terminal with status 'failed' / 'cancelled' must roll the
|
||||
// task back to 'todo'. Otherwise the task is stuck in 'in_progress'
|
||||
// with no owner — checkoutTask refuses (WHERE status IN todo/backlog),
|
||||
// and the scheduler loops forever logging "lost checkout race". The
|
||||
// common trigger is workspace-lock contention: `dispatchRunToWorker`
|
||||
// bails before work starts and calls finishRun('failed').
|
||||
const runNeverStarted =
|
||||
run.startedAt === null && (input.status === "failed" || input.status === "cancelled");
|
||||
const ts = nowIso();
|
||||
|
||||
// Cost is owned by CostWriter — per-message rows in `cost_entries`.
|
||||
@@ -137,6 +146,33 @@ export function buildRunMethods(
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, run.taskId, input.runId);
|
||||
if (runNeverStarted) {
|
||||
// Roll the task back to `todo` when no *other* live run claims
|
||||
// it. Invariant: `task.status = 'in_progress'` ⇒ a queued or
|
||||
// running run owns it. We drop the previous
|
||||
// `checkout_run_id IS NULL AND active_run_id IS NULL` guard
|
||||
// because the conditional UPDATE two lines up can miss (race:
|
||||
// checkout_run_id already swapped to a newer run id), which
|
||||
// would leave the task silently stuck at in_progress. The
|
||||
// NOT EXISTS clause is the canonical "no live owner" check.
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo',
|
||||
checkout_run_id = NULL,
|
||||
execution_locked_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'in_progress'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runs r
|
||||
WHERE r.task_id = tasks.id
|
||||
AND r.id != ?
|
||||
AND r.status IN ('queued', 'running')
|
||||
)`,
|
||||
)
|
||||
.run(ts, run.taskId, input.runId);
|
||||
}
|
||||
}
|
||||
if (runCostUsd > 0) {
|
||||
if (run.taskId !== null) {
|
||||
|
||||
Reference in new issue
Block a user