Skip to content

Commit 1e5d3cc

Browse files
committed
workflow: isolate decisions and resync agent tasks
1 parent 13d3c2b commit 1e5d3cc

17 files changed

Lines changed: 1092 additions & 47 deletions

‎src/agents/coordinator.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
inboxDead,
3030
} from "./paths.js";
3131
import { ResidentAgent } from "./resident-agent.js";
32+
import { syncWorkflowStatusToCurrentState } from "./status-store.js";
33+
import { syncTasksToCurrentState } from "./task-store.js";
3234
import {
3335
DEFAULT_RECONCILE_INTERVAL_MS,
3436
DEFAULT_MAX_TEAMMATES,
@@ -162,10 +164,15 @@ export class AgentCoordinator {
162164
// Spawn teammate worker tasks for non-lead members
163165
await this.spawnTeammates();
164166

167+
await syncTasksToCurrentState(this.contextDir).catch(() => {});
168+
await syncWorkflowStatusToCurrentState(this.contextDir, this.leadMemberId ?? "lead").catch(() => {});
169+
165170
// Start periodic housekeeping (mailbox + tasks)
166171
this.housekeepTimer = setInterval(async () => {
167172
if (this.stopped) return;
168173
try {
174+
await syncTasksToCurrentState(this.contextDir);
175+
await syncWorkflowStatusToCurrentState(this.contextDir, this.leadMemberId ?? "lead");
169176
const mailboxResult = await housekeepMailbox({ contextDir: this.contextDir });
170177
const taskResult = await housekeepTasksInProgress({ contextDir: this.contextDir });
171178
// Emit warnings for dead-lettered messages and requeued tasks

‎src/agents/current-state-phase.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export interface CurrentStatePhaseSnapshot {
2020
mtimeMs: number;
2121
}
2222

23+
export interface CurrentStateRoutingSnapshot extends CurrentStatePhaseSnapshot {
24+
runId: string | null;
25+
issueId: string | null;
26+
contractId: string | null;
27+
workspaceStatusFingerprint: string | null;
28+
proofPacketId: string | null;
29+
issuePath: string | null;
30+
}
31+
2332
function assertRecord(value: unknown, label: string): Record<string, unknown> {
2433
if (!value || typeof value !== "object" || Array.isArray(value)) {
2534
throw new Error(`${label} must be an object`);
@@ -54,6 +63,57 @@ export async function readCurrentStatePhaseSnapshot(
5463
};
5564
}
5665

66+
function optionalString(record: Record<string, unknown>, ...keys: string[]): string | null {
67+
for (const key of keys) {
68+
const value = record[key];
69+
if (typeof value === "string" && value.trim()) {
70+
return value.trim();
71+
}
72+
}
73+
return null;
74+
}
75+
76+
export async function readCurrentStateRoutingSnapshot(
77+
contextDir: string,
78+
sourcePath: string,
79+
): Promise<CurrentStateRoutingSnapshot> {
80+
const resolvedPath = resolveCurrentStatePhaseSourcePath(contextDir, sourcePath);
81+
const [raw, sourceStat] = await Promise.all([
82+
readFile(resolvedPath, "utf-8"),
83+
stat(resolvedPath),
84+
]);
85+
const data = assertRecord(JSON.parse(raw), "current-state.json");
86+
const phase = data.phase;
87+
if (typeof phase !== "string" || !phase.trim()) {
88+
throw new Error(`current-state phase source missing string phase: ${resolvedPath}`);
89+
}
90+
const phaseReason = typeof data.phase_reason === "string" ? data.phase_reason.trim() : "";
91+
return {
92+
phase: phase.trim(),
93+
phaseReason,
94+
sourcePath: resolvedPath,
95+
mtimeMs: sourceStat.mtimeMs,
96+
runId: optionalString(data, "run_id"),
97+
issueId: optionalString(data, "canonical_issue_id", "issue_id"),
98+
contractId: optionalString(data, "contract_id"),
99+
workspaceStatusFingerprint: optionalString(data, "workspace_status_fingerprint"),
100+
proofPacketId: optionalString(data, "proof_packet_id"),
101+
issuePath: optionalString(data, "issue_path"),
102+
};
103+
}
104+
105+
export function currentStateRoutingKey(snapshot: CurrentStateRoutingSnapshot): string {
106+
return JSON.stringify({
107+
source_path: snapshot.sourcePath,
108+
run_id: snapshot.runId,
109+
issue_id: snapshot.issueId,
110+
contract_id: snapshot.contractId,
111+
workspace_status_fingerprint: snapshot.workspaceStatusFingerprint,
112+
proof_packet_id: snapshot.proofPacketId,
113+
issue_path: snapshot.issuePath,
114+
});
115+
}
116+
57117
export function workflowStatusSummaryForPhase(phase: string): string {
58118
switch (phase) {
59119
case "awaiting-reviewer-fast-gates":

‎src/agents/housekeeping.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { DEFAULT_PROCESSING_TTL_MS, MAX_DELIVERY_ATTEMPTS } from "./constants.js";
2121
import { readMembers } from "./store.js";
2222
import type { AgentMessage, AgentTask } from "./types.js";
23-
import { reconcilePhaseGuardedTasks } from "./task-store.js";
23+
import { syncTasksToCurrentState } from "./task-store.js";
2424

2525
export interface HousekeepResult {
2626
requeued: number;
@@ -194,10 +194,16 @@ export async function housekeepTasksInProgress(
194194
const result: TaskHousekeepResult = { requeued: 0, warnings: [] };
195195
const now = Date.now();
196196

197-
const phaseTransitions = await reconcilePhaseGuardedTasks(opts.contextDir);
197+
const routingSync = await syncTasksToCurrentState(opts.contextDir);
198+
const phaseTransitions = routingSync.phaseTransitions;
198199
if (phaseTransitions > 0) {
199200
result.warnings.push(`Reconciled ${phaseTransitions} phase-guarded task transition(s)`);
200201
}
202+
if (routingSync.added > 0 || routingSync.superseded > 0) {
203+
result.warnings.push(
204+
`Synced ${routingSync.added} routed task(s), superseded ${routingSync.superseded} stale task(s)`,
205+
);
206+
}
201207

202208
const inProgressDir = tasksStatusDir(opts.contextDir, "in_progress");
203209
const pendingDir = tasksStatusDir(opts.contextDir, "pending");

‎src/agents/paths.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export function tasksEventsPath(contextDir: string): string {
103103
return resolve(tasksRoot(contextDir), "_events.jsonl");
104104
}
105105

106+
export function taskTemplatesPath(contextDir: string): string {
107+
return resolve(agentsRoot(contextDir), "task-templates.json");
108+
}
109+
106110
// -- Inbox summary --------------------------------------------------------
107111

108112
export function inboxSummaryPath(contextDir: string): string {

‎src/agents/status-store.ts‎

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
workflowStatusNextActionsForPhase,
1010
workflowStatusSummaryForPhase,
1111
} from "./current-state-phase.js";
12+
import { listTasks, readTaskTemplates } from "./task-store.js";
1213

1314
export interface WorkflowStatusSummary {
1415
version: "1";
@@ -73,41 +74,73 @@ function buildDerivedWorkflowStatus(
7374
};
7475
}
7576

76-
export async function readWorkflowStatus(
77+
async function detectWorkflowStatusSourcePath(
7778
contextDir: string,
79+
current: WorkflowStatusSummary | null,
80+
): Promise<string | null> {
81+
if (current?.source?.kind === "current_state_phase_v1" && current.source.path.trim()) {
82+
return current.source.path;
83+
}
84+
const templates = await readTaskTemplates(contextDir);
85+
const templateSource = templates.find((template) => template.phase_guard?.source_path)?.phase_guard?.source_path;
86+
if (templateSource) return templateSource;
87+
88+
const tasks = await listTasks(contextDir);
89+
const taskSource = tasks.find((task) => task.phase_guard?.source_path)?.phase_guard?.source_path;
90+
return taskSource ?? null;
91+
}
92+
93+
export async function syncWorkflowStatusToCurrentState(
94+
contextDir: string,
95+
ownerMemberId = "lead",
96+
sourcePath?: string | null,
7897
): Promise<WorkflowStatusSummary | null> {
98+
let current: WorkflowStatusSummary | null = null;
7999
try {
80100
const raw = await readFile(workflowStatusPath(contextDir), "utf-8");
81101
const data = JSON.parse(raw);
82102
assertWorkflowStatusSummary(data);
83-
if (data.source?.kind === "current_state_phase_v1") {
84-
try {
85-
const snapshot = await readCurrentStatePhaseSnapshot(contextDir, data.source.path);
86-
const derived = buildDerivedWorkflowStatus(data.owner_member_id, snapshot);
87-
const sourceMatches = data.source.path === derived.source?.path
88-
&& data.source.mtime_ms === derived.source?.mtime_ms;
89-
if (
90-
!sourceMatches
91-
|| data.summary !== derived.summary
92-
|| !arraysEqual(data.blockers, derived.blockers)
93-
|| !arraysEqual(data.next_actions, derived.next_actions)
94-
) {
95-
const root = agentsRoot(contextDir);
96-
const tmpDir = resolve(root, "tmp");
97-
await mkdir(dirname(workflowStatusPath(contextDir)), { recursive: true });
98-
await atomicJsonWrite(tmpDir, workflowStatusPath(contextDir), derived);
99-
return derived;
100-
}
101-
} catch {
102-
// Fall back to the persisted summary if the source is temporarily unreadable.
103-
}
103+
current = data;
104+
} catch {
105+
current = null;
106+
}
107+
108+
const resolvedSourcePath = sourcePath ?? await detectWorkflowStatusSourcePath(contextDir, current);
109+
if (!resolvedSourcePath) {
110+
return current;
111+
}
112+
113+
try {
114+
const snapshot = await readCurrentStatePhaseSnapshot(contextDir, resolvedSourcePath);
115+
const derived = buildDerivedWorkflowStatus(current?.owner_member_id ?? ownerMemberId, snapshot);
116+
if (
117+
current
118+
&& current.source?.kind === "current_state_phase_v1"
119+
&& current.source.path === derived.source?.path
120+
&& current.source.mtime_ms === derived.source?.mtime_ms
121+
&& current.summary === derived.summary
122+
&& arraysEqual(current.blockers, derived.blockers)
123+
&& arraysEqual(current.next_actions, derived.next_actions)
124+
) {
125+
return current;
104126
}
105-
return data;
127+
128+
const root = agentsRoot(contextDir);
129+
const tmpDir = resolve(root, "tmp");
130+
await mkdir(dirname(workflowStatusPath(contextDir)), { recursive: true });
131+
await atomicJsonWrite(tmpDir, workflowStatusPath(contextDir), derived);
132+
return derived;
106133
} catch {
107-
return null;
134+
return current;
108135
}
109136
}
110137

138+
export async function readWorkflowStatus(
139+
contextDir: string,
140+
): Promise<WorkflowStatusSummary | null> {
141+
return await syncWorkflowStatusToCurrentState(contextDir);
142+
}
143+
111144
export async function writeWorkflowStatus(
112145
opts: WriteWorkflowStatusOptions,
113146
): Promise<WorkflowStatusSummary> {

0 commit comments

Comments
 (0)