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:
ZacharyZhang-NY committed 2026-04-18 21:37:56 -04:00
1 parent 61fb4b947f
commit ff5e22092a
45 files changed
+3409 -300

No files matched your search

+264
View File
@@ -305,4 +305,268 @@ describe("CAS checkout", () => {
it("STALE_LOCK_TIMEOUT_MS is 15 minutes", () => {
expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000);
});
it("failed dispatch (run never started) rolls task back to 'todo' so heartbeat can retry", () => {
// Regression: workspace-lock contention calls finishRun('failed') on a
// run that never reached 'running'. The old code cleared checkout_run_id
// but left task.status='in_progress' — the next checkoutTask pass then
// refused (WHERE status IN ('todo','backlog')), and the scheduler
// deadlocked while logging "lost checkout race" every 15 seconds.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, workspaceId);
const taskId = createTestTask(db, companyId, null, workspaceId);
const { id: runId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
db.checkoutTask(taskId, runId);
expect(db.getTask(taskId).status).toBe("in_progress");
// Never call markRunStatus('running', ...) — this simulates the
// "workspace already locked" bail-out inside dispatchRunToWorker.
db.finishRun({
runId,
status: "failed",
summary: null,
errorMessage: "Workspace is locked by another run.",
exitCode: null,
signal: null,
model: null,
sessionDisplayId: null,
unattributedCost: true,
});
expect(db.getTask(taskId).status).toBe("todo");
// And the next CAS checkout must succeed — proving the heartbeat
// retry path is unblocked.
const { id: retryRunId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
expect(db.checkoutTask(taskId, retryRunId)).toBe(true);
});
it("cancelled run that never started also rolls task back to 'todo'", () => {
// Workspace-contention bail-out in dispatchRunToWorker marks the run as
// 'cancelled' (not 'failed'), so the Dashboard "Needs attention" banner
// stays quiet. The rollback path must handle cancelled too, or the task
// would stick in 'in_progress' just like the original 'failed' bug.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, workspaceId);
const taskId = createTestTask(db, companyId, null, workspaceId);
const { id: runId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
db.checkoutTask(taskId, runId);
db.finishRun({
runId,
status: "cancelled",
summary: null,
errorMessage: "Workspace in use by another run; will retry.",
exitCode: null,
signal: null,
model: null,
sessionDisplayId: null,
unattributedCost: true,
});
expect(db.getTask(taskId).status).toBe("todo");
});
it("failed run that did start stays 'in_progress' (human retry path)", () => {
// Counterpart to the regression above: a run that actually transitioned
// to 'running' and then failed mid-flight is the user's problem to
// resolve through the recovery UI — we must not silently reset its
// task to 'todo' and lose the partial work / error context.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, workspaceId);
const taskId = createTestTask(db, companyId, null, workspaceId);
const { id: runId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
db.checkoutTask(taskId, runId);
db.markRunStatus(runId, "running", "");
db.finishRun({
runId,
status: "failed",
summary: null,
errorMessage: "Worker crashed",
exitCode: 1,
signal: null,
model: null,
sessionDisplayId: null,
unattributedCost: false,
});
expect(db.getTask(taskId).status).toBe("in_progress");
});
it("startup reconciliation unsticks tasks whose checkout_run_id points at a dead run", () => {
// This is the shape observed in the wild: active_run_id cleared by
// finishRun (unconditional clear), but checkout_run_id still carrying
// a dead run id because the conditional clear's WHERE missed due to
// a race. Task sits in 'in_progress' forever — heartbeat picks it
// (activeRunId null), checkoutTask refuses (status != todo/backlog),
// "lost checkout race" on loop. Reconciliation must fix it without
// caring which reference column is null.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, workspaceId);
const taskId = createTestTask(db, companyId, null, workspaceId);
const { id: deadRunId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
db.checkoutTask(taskId, deadRunId);
// Mark the run terminal without running finishRun's clear path —
// simulates the race where checkout_run_id survived but the run
// itself is no longer live.
(db as unknown as RawDb).db
.prepare("UPDATE runs SET status = 'cancelled' WHERE id = ?")
.run(deadRunId);
(db as unknown as RawDb).db
.prepare("UPDATE tasks SET active_run_id = NULL WHERE id = ?")
.run(taskId);
// Pre-condition: task is stuck.
const stuck = (db as unknown as RawDb).db
.prepare("SELECT status, checkout_run_id as checkoutRunId, active_run_id as activeRunId FROM tasks WHERE id = ?")
.get(taskId) as { status: string; checkoutRunId: string | null; activeRunId: string | null };
expect(stuck.status).toBe("in_progress");
expect(stuck.checkoutRunId).toBe(deadRunId);
expect(stuck.activeRunId).toBeNull();
// Re-initialise: reconciliation should run.
db.close();
db = new AppDatabase(tempDir);
db.init();
expect(db.lastOrphanResetCount).toBe(1);
const reclaimed = db.getTask(taskId);
expect(reclaimed.status).toBe("todo");
// And a fresh checkout must succeed — proving the task is reclaimable.
const { id: newRunId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
expect(db.checkoutTask(taskId, newRunId)).toBe(true);
});
it("startup reconciliation unsticks in_review tasks with no possible reviewer", () => {
// Observed in production: a fresh `ai_ceo` company with only a CEO
// self-assigned a large task and set status=in_review. The reviewer
// would be the assignee's manager (walk up reports_to), but the CEO
// has no manager. With `requires_user_review = 0` there is no human
// fallback either. The heartbeat falls into a loop re-proposing
// `approve_ceo_strategy` every cycle without ever hiring or moving
// the task. Demoting to 'todo' breaks the loop.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
// Top-level agent (CEO): reports_to stays NULL.
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
// Manually wedge the task into the dead-letter shape.
(db as unknown as RawDb).db
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 0 WHERE id = ?")
.run(taskId);
expect(db.getTask(taskId).status).toBe("in_review");
// Re-init triggers reconciliation.
db.close();
db = new AppDatabase(tempDir);
db.init();
expect(db.lastUnreviewableResetCount).toBe(1);
expect(db.getTask(taskId).status).toBe("todo");
});
it("in_review tasks requiring user review are left alone", () => {
// Opposite guard: when requires_user_review = 1 the task is
// intentionally parked waiting for a human — the reconciler must
// not steal it.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
(db as unknown as RawDb).db
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 1 WHERE id = ?")
.run(taskId);
db.close();
db = new AppDatabase(tempDir);
db.init();
expect(db.lastUnreviewableResetCount).toBe(0);
expect(db.getTask(taskId).status).toBe("in_review");
});
it("reconciliation does not fire when interruptStaleRuns already handled a running task", () => {
// Opposite guard: tasks whose run was `running` at shutdown are
// handled earlier in init by `interruptStaleRuns` — it marks the
// run 'interrupted' and sets the task to 'blocked' for human
// review. By the time reconciliation runs, the task is no longer
// 'in_progress', so the cleanup's WHERE clause skips it. The user
// sees the task surfaced in the recovery UI, not silently reset.
const companyId = createTestCompany(db);
createTestConnector(db);
const workspaceId = createTestWorkspace(db, companyId);
const agentId = createTestAgent(db, companyId, workspaceId);
const taskId = createTestTask(db, companyId, null, workspaceId);
const { id: liveRunId } = db.createRun({
companyId,
taskId,
agentId,
workspaceId,
connectorId: "openai-codex",
});
db.checkoutTask(taskId, liveRunId);
db.markRunStatus(liveRunId, "running", "");
db.close();
db = new AppDatabase(tempDir);
db.init();
// Reconciliation counted 0 because interruptStaleRuns moved the
// task to 'blocked' first. Both guards collaborate — neither
// steals a task out from under an actively-working run.
expect(db.lastOrphanResetCount).toBe(0);
expect(db.getTask(taskId).status).toBe("blocked");
});
});
+3 -2
View File
@@ -206,11 +206,12 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
});
const session = db.getActiveTaskSession(taskId)!;
const handoff = sessionService.generateHandoff(taskId, session);
// generateHandoff is agent-scoped: it walks the agent's runs across
// all tasks + taskless heartbeats, not a single task.
const handoff = sessionService.generateHandoff(session);
// Verify handoff markdown content
expect(handoff).toContain("# Session Handoff");
expect(handoff).toContain("Implement session rotation feature"); // task title
expect(handoff).toContain("## Recent Run History");
// Archive the old session with handoff
+332
View File
@@ -0,0 +1,332 @@
import { describe, expect, it } from "vitest";
import type { AgentRecord, ConnectorId, ConnectorRecord, ModelCapability } from "@shared/types";
import {
pickFromCandidates,
pickModelForAgent,
type EnabledModel,
type PickModelDeps,
} from "../../src/main/model-dispatch";
/**
* Minimum fields required to build a plausible AgentRecord for the
* dispatcher. The shared type has many optional/legacy columns; the picker
* only reads role, title, roleLevel, connectorId, model, modelStrategy, and
* companyId.
*/
function makeAgent(overrides: Partial<AgentRecord>): AgentRecord {
return {
id: "agent-1",
companyId: "company-1",
name: "Test Agent",
role: "engineer",
title: "Staff Engineer",
department: null,
status: "active",
reportsTo: null,
connectorId: "anthropic-api" as ConnectorId,
workspaceId: null,
model: "claude-sonnet-4-6",
capabilities: "[]",
budgetMonthlyUsd: 0,
spentMonthlyUsd: 0,
metadataJson: "{}",
heartbeatEnabled: false,
heartbeatIntervalSec: 120,
lastHeartbeatAt: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
modelStrategy: "auto",
roleLevel: "ic",
...overrides,
};
}
/**
* Build a ConnectorRecord that the execution-readiness check treats as
* healthy. Only the fields the check reads are populated realistically;
* the rest default to empty.
*/
function makeConnector(id: string): ConnectorRecord {
return {
id: id as ConnectorId,
label: id,
description: "",
status: "ready",
command: "",
version: null,
authState: "ok",
lastCheckedAt: null,
lastError: null,
model: null,
envBindingText: "",
notes: "",
capabilityMatrix: {
sessionResume: true,
gracefulCancel: true,
modelDiscovery: true,
structuredTranscript: true,
costAttribution: true,
},
configurationDoc: "",
};
}
/**
* Build picker deps from a flat list of (connectorId, provider, modelId)
* triples + a capability matrix. Anything unspecified behaves as if the
* connector is healthy and has no disabled models.
*/
function makeDeps(options: {
enabledModels: Array<{ connectorId: string; provider: string; modelId: string }>;
capabilities: ModelCapability[];
ceoAssignment?: { connectorId: string; modelId: string | null } | null;
}): PickModelDeps {
const connectorsById = new Map<string, ConnectorRecord>();
for (const entry of options.enabledModels) {
if (!connectorsById.has(entry.connectorId)) {
connectorsById.set(entry.connectorId, makeConnector(entry.connectorId));
}
}
return {
getConnector: (id) => {
const c = connectorsById.get(id);
if (!c) throw new Error(`unknown connector ${id}`);
return c;
},
getDisabledModels: () => [],
getCeoAssignment: () =>
options.ceoAssignment
? {
connectorId: options.ceoAssignment.connectorId as ConnectorId,
modelId: options.ceoAssignment.modelId,
}
: null,
listModelCapabilities: () => options.capabilities,
readConnectors: () => Array.from(connectorsById.values()),
};
}
/**
* Patch the getConnectorModelPolicy path used inside listEnabledModels.
* We don't want to stand up the full connector definition catalog for
* every test, so we intercept policy queries by wiring each connector's
* provider + model list through a per-test lookup.
*
* The picker imports `getConnectorDefinition` and `getConnectorModelPolicy`
* at module scope. Integration tests stand up the whole system; these
* unit tests exercise the pure scoring math by stubbing those imports via
* vitest.
*/
// Stubbing is handled by the helper above — tests below only assert the
// sort/filter/fallback behaviour on a hand-crafted capability matrix plus
// a small enabled-model set. The heavy integration path (connector
// definitions + policy) is covered by higher-level tests.
describe("pickModelForAgent", () => {
const claudeOpus: ModelCapability = {
provider: "anthropic",
modelId: "claude-opus-4-7",
reasoning: 5,
coding: 5,
writing: 5,
tier: "hard",
costPerMtokOut: 75,
updatedAt: "",
};
const llama: ModelCapability = {
provider: "fireworks",
modelId: "llama-70b",
reasoning: 2,
coding: 2,
writing: 2,
tier: "trivial",
costPerMtokOut: 1,
updatedAt: "",
};
it("explicit strategy honours agent.connectorId + agent.model verbatim", () => {
const agent = makeAgent({
modelStrategy: "explicit",
connectorId: "anthropic-api" as ConnectorId,
model: "claude-opus-4-7",
});
const deps = makeDeps({
enabledModels: [
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
],
capabilities: [claudeOpus],
});
const assignment = pickModelForAgent(agent, deps);
expect(assignment).toEqual({ connectorId: "anthropic-api", modelId: "claude-opus-4-7" });
});
it("CEO role always takes the user-picked pair regardless of modelStrategy", () => {
const agent = makeAgent({
modelStrategy: "auto",
roleLevel: "c_suite",
role: "CEO",
connectorId: "anthropic-api" as ConnectorId,
model: "claude-opus-4-7",
});
const deps = makeDeps({
enabledModels: [
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
],
capabilities: [claudeOpus, llama],
});
const assignment = pickModelForAgent(agent, deps);
expect(assignment.modelId).toBe("claude-opus-4-7");
});
it("CEO is identified by reportsTo === null even when roleLevel stays at the schema default 'ic'", () => {
// Regression test for the v39 migration gap: CEO agents created before
// the explicit role_level hookup have roleLevel = 'ic' in the DB. The
// dispatcher must still recognise them via the DB invariant used by
// getCeoForCompany: reports_to IS NULL.
const agent = makeAgent({
modelStrategy: "auto",
roleLevel: "ic",
reportsTo: null,
role: "CEO",
connectorId: "anthropic-api" as ConnectorId,
model: "claude-opus-4-7",
});
const deps = makeDeps({
enabledModels: [
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
],
capabilities: [claudeOpus, llama],
});
const assignment = pickModelForAgent(agent, deps);
expect(assignment.modelId).toBe("claude-opus-4-7");
});
});
describe("inferTaskProfile — i18n", () => {
it("classifies Chinese role strings via substring match", async () => {
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
expect(inferTaskProfile("高级架构师", null, "director").domain).toBe("reasoning");
expect(inferTaskProfile("前端工程师", null, "ic").domain).toBe("coding");
expect(inferTaskProfile("内容运营", null, "ic").domain).toBe("writing");
expect(inferTaskProfile("产品经理", null, "manager").domain).toBe("writing");
});
it("recognises Chinese trivial-tier cues", async () => {
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
expect(inferTaskProfile("实习生", "数据标注实习生", "ic").requiredTier).toBe("trivial");
expect(inferTaskProfile("初级工程师", null, "ic").requiredTier).toBe("trivial");
expect(inferTaskProfile("助理", null, "ic").requiredTier).toBe("trivial");
});
it("English classification still works after unicode tokenizer swap", async () => {
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
expect(inferTaskProfile("Senior Architect", null, "director").domain).toBe("reasoning");
expect(inferTaskProfile("Staff Engineer", null, "lead").domain).toBe("coding");
expect(inferTaskProfile("Marketing Lead", null, "lead").domain).toBe("writing");
// 'lead' keyword must not match 'leaderboard' / 'cleaner' substrings —
// token-based matching is still in effect for Latin keywords.
expect(inferTaskProfile("Cleaner", null, "ic").domain).toBe("writing");
});
});
describe("pickFromCandidates — pure rubric", () => {
const claudeOpus: ModelCapability = {
provider: "anthropic", modelId: "claude-opus-4-7",
reasoning: 5, coding: 5, writing: 5,
tier: "hard", costPerMtokOut: 75, updatedAt: "",
};
const claudeSonnet: ModelCapability = {
provider: "anthropic", modelId: "claude-sonnet-4-6",
reasoning: 4, coding: 5, writing: 5,
tier: "standard", costPerMtokOut: 15, updatedAt: "",
};
const kimiK2: ModelCapability = {
provider: "fireworks", modelId: "kimi-k2",
reasoning: 4, coding: 4, writing: 5,
tier: "standard", costPerMtokOut: 3, updatedAt: "",
};
const llama: ModelCapability = {
provider: "fireworks", modelId: "llama-70b",
reasoning: 2, coding: 2, writing: 2,
tier: "trivial", costPerMtokOut: 1, updatedAt: "",
};
const allEnabled: EnabledModel[] = [
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-sonnet-4-6" },
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "kimi-k2" },
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" },
];
it("picks the cheapest qualifying model (standard/writing → kimi beats sonnet on price)", () => {
const winner = pickFromCandidates(
{ domain: "writing", requiredTier: "standard" },
allEnabled,
[claudeOpus, claudeSonnet, kimiK2, llama],
);
expect(winner?.modelId).toBe("kimi-k2");
});
it("hard tier requires tier≥hard even if domain score clears the floor", () => {
// writing=5 qualifies sonnet and kimi numerically, but both are tier=standard.
// Only opus (tier=hard) survives.
const winner = pickFromCandidates(
{ domain: "writing", requiredTier: "hard" },
allEnabled,
[claudeOpus, claudeSonnet, kimiK2, llama],
);
expect(winner?.modelId).toBe("claude-opus-4-7");
});
it("returns null when no candidate clears the bar", () => {
// Ask for hard/reasoning ≥4 but only provide trivial-tier llama.
const winner = pickFromCandidates(
{ domain: "reasoning", requiredTier: "hard" },
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
[llama],
);
expect(winner).toBeNull();
});
it("returns null on an empty candidate list", () => {
expect(pickFromCandidates({ domain: "coding", requiredTier: "trivial" }, [], [llama])).toBeNull();
});
it("trivial tier still honours the domain floor (2)", () => {
// llama scores 2 on writing — exactly on the floor, so it qualifies.
const winner = pickFromCandidates(
{ domain: "writing", requiredTier: "trivial" },
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
[llama],
);
expect(winner?.modelId).toBe("llama-70b");
});
it("ignores enabled models with no capability row", () => {
// Only claudeOpus has a capability row; mystery model is filtered out
// despite appearing in the enabled list.
const winner = pickFromCandidates(
{ domain: "reasoning", requiredTier: "hard" },
[
{ provider: "mystery", connectorId: "mystery" as ConnectorId, modelId: "unknown" },
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
],
[claudeOpus],
);
expect(winner?.modelId).toBe("claude-opus-4-7");
});
});
describe("pickModelForAgent — internal rubric", () => {
it("exposes DOMAIN_SCORE_FLOOR and TIER_RANK for downstream tests", async () => {
const { __INTERNAL__ } = await import("../../src/main/model-dispatch");
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.trivial).toBe(2);
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.standard).toBe(3);
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.hard).toBe(4);
expect(__INTERNAL__.TIER_RANK.trivial).toBe(1);
expect(__INTERNAL__.TIER_RANK.standard).toBe(2);
expect(__INTERNAL__.TIER_RANK.hard).toBe(3);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
import { resolveWorkspaceWithLockMode } from "../../src/main/orchestration/resolve-workspace";
/**
* Regression tests for the workspace lock policy introduced in v0.4.168+:
* only `task.workspaceId` grants exclusive access. Agent-, project-, and
* company-scoped resolutions are all `shared` so teammates can run in
* parallel instead of queuing on a single workspace lock.
*
* See `resolve-workspace.ts` for the full rationale.
*/
function seedCompany(db: AppDatabase) {
const companyId = db.saveCompany({
name: "Parallel Co",
description: "Regression fixture",
status: "active",
});
db.saveConnector({
id: "openai-codex",
command: "codex",
model: "gpt-5",
envBindingText: "",
notes: "fixture",
});
const workspaceId = db.saveWorkspace({
companyId,
name: "Default",
localPath: "/tmp/fixture",
repoUrl: "https://github.com/example/fixture",
repoRef: "main",
isPrimary: true,
});
return { companyId, workspaceId };
}
describe("resolveWorkspaceWithLockMode", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "resolve-workspace-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
it("task-bound workspace → exclusive (explicit opt-in)", () => {
const { companyId, workspaceId } = seedCompany(db);
const result = resolveWorkspaceWithLockMode(
db,
{ workspaceId, projectId: null },
{ workspaceId: null },
companyId,
);
expect(result.source).toBe("task");
expect(result.lockMode).toBe("exclusive");
expect(result.workspace?.id).toBe(workspaceId);
});
it("agent-bound workspace → shared (normal case: teammates share a repo)", () => {
const { companyId, workspaceId } = seedCompany(db);
const result = resolveWorkspaceWithLockMode(
db,
null,
{ workspaceId },
companyId,
);
expect(result.source).toBe("agent");
expect(result.lockMode).toBe("shared");
});
it("project-bound workspace → shared (team collaboration space)", () => {
const { companyId } = seedCompany(db);
const projectId = db.saveProject({
companyId,
name: "Platform",
description: "",
status: "active",
});
// Create a second workspace bound to the project so the resolver's
// project-primary branch actually fires (the default seeded workspace
// has no project_id set).
db.saveWorkspace({
companyId,
name: "Project repo",
localPath: "/tmp/project-ws",
repoUrl: "https://github.com/example/project",
repoRef: "main",
projectId,
isPrimary: true,
});
const result = resolveWorkspaceWithLockMode(
db,
{ workspaceId: null, projectId },
{ workspaceId: null },
companyId,
);
expect(result.source).toBe("project");
expect(result.lockMode).toBe("shared");
});
it("company-fallback → shared (unchanged legacy behavior)", () => {
const { companyId } = seedCompany(db);
const result = resolveWorkspaceWithLockMode(
db,
null,
{ workspaceId: null },
companyId,
);
expect(result.source).toBe("company_fallback");
expect(result.lockMode).toBe("shared");
});
it("task workspace wins over agent workspace even when both are set", () => {
const { companyId, workspaceId: agentWsId } = seedCompany(db);
const taskWsId = db.saveWorkspace({
companyId,
name: "Task-only",
localPath: "/tmp/task-ws",
repoUrl: "https://github.com/example/task",
repoRef: "main",
isPrimary: false,
});
const result = resolveWorkspaceWithLockMode(
db,
{ workspaceId: taskWsId, projectId: null },
{ workspaceId: agentWsId },
companyId,
);
expect(result.source).toBe("task");
expect(result.lockMode).toBe("exclusive");
expect(result.workspace?.id).toBe(taskWsId);
});
it("no workspace at all → none", () => {
const companyId = db.saveCompany({
name: "Empty Co",
description: "",
status: "active",
});
const result = resolveWorkspaceWithLockMode(
db,
null,
{ workspaceId: null },
companyId,
);
expect(result.source).toBe("none");
expect(result.lockMode).toBe("none");
expect(result.workspace).toBeNull();
});
});
+6 -2
View File
@@ -577,12 +577,16 @@ describe("session management service", () => {
const session = db.getActiveTaskSession(taskId)!;
const svc = createSessionManagementService({ db, logger: mockLogger });
const handoff = svc.generateHandoff(taskId, session);
// Handoff is now agent-scoped: it walks the agent's recent runs
// (across tasks + heartbeats) rather than a single task. The task
// title is no longer in the handoff header because the session
// does not belong to any one task anymore.
const handoff = svc.generateHandoff(session);
expect(handoff).toContain("# Session Handoff");
expect(handoff).toContain("Implement session rotation"); // task title
expect(handoff).toContain("## Recent Run History");
expect(handoff).toContain("succeeded");
expect(handoff).toContain("Implemented initial logic"); // run summary
expect(handoff).toContain("15000 total tokens"); // 10000 + 5000
});
});