mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
fix(daemon): PL-004 Phase D R1 — workflow projection routes through Phase A queue closure primitive + waiting frontier preservation + failed status
Repairs guard BLOCK on R0 commit 55bdcbb:
Blocker 1 — projection bypassed Phase A queue closure/audit contract:
R0's WorkflowProjector.project() did a direct UPDATE on queue_items
that skipped closure_reason / closure_target / handed_off_to / blocked_on
metadata, queue_transitions append, and queue.updated event. R1 routes
all closure through a new Phase A primitive:
- QueueRepository.updateWithinTransaction(): same closure validation +
UPDATE + transition log + queue.updated event as update(), but
composes inside the caller's outer db.transaction (caller responsible
for post-commit notifySubscribers). update() is now factored through
a private updateInTransactionalContext() helper; behavior unchanged
for existing callers.
- QueueUpdateInput.handedOffTo + .blockedOn: optional new fields that
persist the canonical pointer columns. update SQL extended with
COALESCE for both. Backward-compatible with all existing callers.
- workflowExitToQueueClosure() helper translates each workflow exit
into the Phase A queue closure shape:
handoff → state=handed-off, closure_reason=handed_off_to,
closure_target+handed_off_to=<next-owner>
waiting → state=blocked, closure_reason=blocked_on,
closure_target+blocked_on=<blocker>
done → state=done, closure_reason=no-follow-on
failed → state=done, closure_reason=denied
- WorkflowProjector.project() now resolves the next owner BEFORE
closure (so closure_target is set), calls
queueRepo.updateWithinTransaction(), then optionally
queueRepo.createWithinTransaction(). All inside one outer
db.transaction. Phase A hot-potato strict-rejection invariants
(state=done requires closure_reason; handed_off_to/blocked_on/
escalation also require closure_target) apply unchanged — workflow
projection cannot land state=done without a valid closure_reason.
Blocker 2a — exit=failed treated as completed:
R0 had a frontier-empty branch that always set status=completed.
R1 makes exit=failed a distinct terminal state:
- status=failed, completedAt stamped
- emits workflow.failed (NOT workflow.completed)
- failed event carries the reason from input.resultNote
- workflow.failed and workflow.completed are now mutually exclusive
Blocker 2b — waiting frontier bug:
R0 removed the closed packet from currentFrontier even on exit=waiting.
That broke workflow-keepalive's owner resolution: a single-packet
waiting workflow had no owner to wake. R1 PRESERVES the closed packet
on currentFrontier when exit=waiting; workflow-keepalive can still
resolve the blocked owner via Phase A queue_items destination.
R1 regression tests (4 new in workflow-projector.test.ts):
- "project(handoff) persists Phase A queue closure metadata + transition
row + queue.updated event": asserts closure_reason=handed_off_to,
closure_target=<next-owner>, handed_off_to=<next-owner>; queue_transitions
row with closure_reason; queue.updated event emitted.
- "project(done) without exposed closure overrides defaults closure_reason
to no-follow-on": asserts default closure_reason path.
- "project(waiting) uses Phase A blocked state + closure_reason=blocked_on
+ blocked_on column": asserts blocked-state Phase A shape.
- "project(failed) sets workflow status=failed AND emits workflow.failed
(NOT completed)": asserts the formerly-merged failed-vs-completed split.
The existing waiting frontier test renamed + actually asserts
currentFrontier preservation:
- "project(waiting) sets status=waiting AND preserves the closed packet
on currentFrontier (workflow-keepalive resolvability)": asserts
projected.instance.currentFrontier still contains the closed packet
AND re-fetched instance state agrees.
Verification:
- tsc clean for daemon and cli.
- Per-package vitest baseline under explicit OPENRIG_HOME +
OPENRIG_DB=:memory: temp env:
- packages/daemon: 2556 / 2556 pass (was 2552 in Phase D R0; +4
R1 regressions in workflow-projector.test.ts).
- packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
CLI failures unchanged from prior baseline.
Boundaries preserved (R1):
- Worktree-only on phase-d-coordination-primitive-system; main untouched.
- QueueRepository.updateWithinTransaction is a NEW method; existing
update() unchanged (factored through new private helper).
- QueueUpdateInput optional fields handedOffTo + blockedOn are
additive; existing callers unaffected.
- No-touch surfaces unchanged: Phase A non-extension queue surfaces
beyond the orch-ratified Phase D extension points (createWithinTransaction
+ updateWithinTransaction + maybeNudge visibility), Phase B
project/classifier/view, Lifecycle v2, migrations 001-032,
POC source. Phase C already-extended surfaces unchanged from R0.
- No fleet sweep, no protected-host mutation, no external spend, no
destructive lifecycle, no VM-boundary actions, no remote push,
no tag, no publish.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
55bdcbb73c
commit
bf28cc2f7e
3 files changed
+353
-61
No files matched your search
@@ -118,6 +118,19 @@ export interface QueueUpdateInput {
|
||||
transitionNote?: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
/**
|
||||
* PL-004 Phase D extension: when set, persists the queue_items.handed_off_to
|
||||
* column. Used by workflow-projector for state=handed-off transitions so
|
||||
* the canonical "next owner" pointer is recoverable from queue state alone.
|
||||
* Optional to preserve backward compatibility with existing update() callers.
|
||||
*/
|
||||
handedOffTo?: string;
|
||||
/**
|
||||
* PL-004 Phase D extension: when set, persists the queue_items.blocked_on
|
||||
* column. Used by workflow-projector for state=blocked transitions so the
|
||||
* blocker reference (qitem id, gate name) is recoverable from queue state.
|
||||
*/
|
||||
blockedOn?: string;
|
||||
}
|
||||
|
||||
export interface QueueHandoffInput {
|
||||
@@ -760,6 +773,47 @@ export class QueueRepository {
|
||||
* invisible to the view bridge.
|
||||
*/
|
||||
update(input: QueueUpdateInput): QueueItem {
|
||||
const txn = this.db.transaction(() => this.updateInTransactionalContext(input));
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(input.qitemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PL-004 Phase D extension point (orch-ratified per slice IMPL Driver
|
||||
* Handoff Contract / Guard R1 repair). Same closure validation +
|
||||
* UPDATE + transition log + queue.updated event as update(), but
|
||||
* runs inside the caller's outer db.transaction so it composes with
|
||||
* workflow-projector's transactional-scribe contract.
|
||||
*
|
||||
* Caller MUST:
|
||||
* 1. Invoke from inside a `db.transaction(() => {...})` block.
|
||||
* 2. After the outer txn commits, call:
|
||||
* eventBus.notifySubscribers(persistedEvent)
|
||||
* 3. NOT call this from outside a transaction (will produce a
|
||||
* half-state if the caller errors before committing).
|
||||
*
|
||||
* Closure validation runs at call time (before the UPDATE) so a
|
||||
* Phase A invariant violation (e.g., state=done without closure_reason)
|
||||
* throws before the workflow projector's outer transaction can commit
|
||||
* any partial state. The Phase A hot-potato strict-rejection rule
|
||||
* therefore applies to workflow projection unchanged.
|
||||
*/
|
||||
updateWithinTransaction(input: QueueUpdateInput): {
|
||||
qitemId: string;
|
||||
persistedEvent: PersistedEvent;
|
||||
} {
|
||||
const persistedEvent = this.updateInTransactionalContext(input);
|
||||
return { qitemId: input.qitemId, persistedEvent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: closure validation + UPDATE + transition log + emit
|
||||
* queue.updated event. Caller is responsible for transaction wrapping
|
||||
* (the public update() wraps; the public updateWithinTransaction()
|
||||
* composes inside the caller's outer transaction).
|
||||
*/
|
||||
private updateInTransactionalContext(input: QueueUpdateInput): PersistedEvent {
|
||||
const qitem = this.getById(input.qitemId);
|
||||
if (!qitem) {
|
||||
throw new QueueRepositoryError(
|
||||
@@ -788,47 +842,45 @@ export class QueueRepository {
|
||||
const ts = new Date().toISOString();
|
||||
const fromState = qitem.state;
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = ?,
|
||||
ts_updated = ?,
|
||||
closure_reason = COALESCE(?, closure_reason),
|
||||
closure_target = COALESCE(?, closure_target)
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(
|
||||
input.state,
|
||||
ts,
|
||||
validation.closureReason,
|
||||
validation.closureTarget,
|
||||
input.qitemId
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = ?,
|
||||
ts_updated = ?,
|
||||
closure_reason = COALESCE(?, closure_reason),
|
||||
closure_target = COALESCE(?, closure_target),
|
||||
handed_off_to = COALESCE(?, handed_off_to),
|
||||
blocked_on = COALESCE(?, blocked_on)
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(
|
||||
input.state,
|
||||
ts,
|
||||
validation.closureReason,
|
||||
validation.closureTarget,
|
||||
input.handedOffTo ?? null,
|
||||
input.blockedOn ?? null,
|
||||
input.qitemId
|
||||
);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
state: input.state,
|
||||
actorSession: input.actorSession,
|
||||
transitionNote: input.transitionNote,
|
||||
closureReason: validation.closureReason ?? undefined,
|
||||
closureTarget: validation.closureTarget ?? undefined,
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.updated",
|
||||
qitemId: input.qitemId,
|
||||
fromState,
|
||||
toState: input.state,
|
||||
closureReason: validation.closureReason ?? null,
|
||||
closureTarget: validation.closureTarget ?? null,
|
||||
actorSession: input.actorSession,
|
||||
});
|
||||
this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
state: input.state,
|
||||
actorSession: input.actorSession,
|
||||
transitionNote: input.transitionNote,
|
||||
closureReason: validation.closureReason ?? undefined,
|
||||
closureTarget: validation.closureTarget ?? undefined,
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(input.qitemId);
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.updated",
|
||||
qitemId: input.qitemId,
|
||||
fromState,
|
||||
toState: input.state,
|
||||
closureReason: validation.closureReason ?? null,
|
||||
closureTarget: validation.closureTarget ?? null,
|
||||
actorSession: input.actorSession,
|
||||
});
|
||||
}
|
||||
|
||||
getById(qitemId: string): QueueItem | null {
|
||||
|
||||
@@ -155,19 +155,8 @@ export class WorkflowProjector {
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
// 1. Close the current packet via direct UPDATE inside this txn.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items SET state = ?, ts_updated = ? WHERE qitem_id = ?`,
|
||||
)
|
||||
.run(
|
||||
input.exit === "handoff" ? "handed-off" : input.exit === "waiting" ? "blocked" : "done",
|
||||
evaluatedAt,
|
||||
input.currentPacketId,
|
||||
);
|
||||
|
||||
// 2. If exit is handoff: create next-step qitem in same txn.
|
||||
let createdNext: { qitemId: string; persistedEvent: PersistedEvent; destinationSession: string; nudge: boolean | undefined } | null = null;
|
||||
// 1. Resolve next owner BEFORE closing prior so closure_target is set.
|
||||
let resolvedNextOwner: string | null = null;
|
||||
if (input.exit === "handoff") {
|
||||
if (!nextStep) {
|
||||
throw new WorkflowProjectorError(
|
||||
@@ -184,11 +173,36 @@ export class WorkflowProjector {
|
||||
{ instanceId: instance.instanceId, nextStepId: nextStep.id, nextRole: nextStep.actor_role },
|
||||
);
|
||||
}
|
||||
nextOwnerSession = owner;
|
||||
resolvedNextOwner = owner;
|
||||
}
|
||||
|
||||
// 2. R1 fix (guard blocker 1): close the current packet via Phase A's
|
||||
// QueueRepository.updateWithinTransaction. This validates closure
|
||||
// (Phase A hot-potato strict-rejection invariants), persists closure
|
||||
// metadata (closure_reason, closure_target, handed_off_to / blocked_on),
|
||||
// appends queue_transitions, and emits queue.updated — all inside this
|
||||
// outer transaction. The Phase A closure authority is preserved.
|
||||
const closure = workflowExitToQueueClosure(input, resolvedNextOwner);
|
||||
const queueUpdate = this.queueRepo.updateWithinTransaction({
|
||||
qitemId: input.currentPacketId,
|
||||
actorSession: input.actorSession,
|
||||
state: closure.state,
|
||||
closureReason: closure.closureReason,
|
||||
closureTarget: closure.closureTarget ?? undefined,
|
||||
handedOffTo: closure.handedOffTo,
|
||||
blockedOn: closure.blockedOn,
|
||||
transitionNote: closure.transitionNote,
|
||||
});
|
||||
persistedEvents.push(queueUpdate.persistedEvent);
|
||||
|
||||
// 3. If exit is handoff: create next-step qitem in same txn.
|
||||
let createdNext: { qitemId: string; persistedEvent: PersistedEvent; destinationSession: string; nudge: boolean | undefined } | null = null;
|
||||
if (input.exit === "handoff" && nextStep && resolvedNextOwner) {
|
||||
nextOwnerSession = resolvedNextOwner;
|
||||
nextStepId = nextStep.id;
|
||||
createdNext = this.queueRepo.createWithinTransaction({
|
||||
sourceSession: input.actorSession,
|
||||
destinationSession: owner,
|
||||
destinationSession: resolvedNextOwner,
|
||||
body: workflowHandoffBody({
|
||||
spec,
|
||||
instance,
|
||||
@@ -209,7 +223,7 @@ export class WorkflowProjector {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Append step trail entry (prior + next ids).
|
||||
// 4. Append step trail entry (prior + next ids).
|
||||
this.trailLog.record({
|
||||
instanceId: instance.instanceId,
|
||||
stepId: currentStep.id,
|
||||
@@ -222,15 +236,33 @@ export class WorkflowProjector {
|
||||
priorQitemId: input.currentPacketId,
|
||||
});
|
||||
|
||||
// 4. Update instance frontier + status.
|
||||
// 5. Update instance frontier + status.
|
||||
// R1 fix (guard blocker 2b): on exit=waiting, PRESERVE the closed
|
||||
// packet on the frontier. workflow-keepalive treats waiting as
|
||||
// eligible and resolves owners from current_frontier_json; removing
|
||||
// the packet would leave a single-packet waiting workflow with no
|
||||
// owner to wake.
|
||||
const remainingFrontier = instance.currentFrontier.filter(
|
||||
(id) => id !== input.currentPacketId,
|
||||
);
|
||||
const nextFrontier = nextQitemId ? [...remainingFrontier, nextQitemId] : remainingFrontier;
|
||||
let nextFrontier: string[];
|
||||
if (input.exit === "waiting") {
|
||||
// Keep the closed packet on frontier; the watchdog uses it to wake the owner.
|
||||
nextFrontier = [...remainingFrontier, input.currentPacketId];
|
||||
} else if (nextQitemId) {
|
||||
nextFrontier = [...remainingFrontier, nextQitemId];
|
||||
} else {
|
||||
nextFrontier = remainingFrontier;
|
||||
}
|
||||
// R1 fix (guard blocker 2a): exit=failed sets status=failed, NOT
|
||||
// completed. Emits workflow.failed instead of workflow.completed.
|
||||
let nextStatus: WorkflowInstance["status"];
|
||||
let completedAt: string | null = null;
|
||||
if (input.exit === "waiting") {
|
||||
nextStatus = "waiting";
|
||||
} else if (input.exit === "failed") {
|
||||
nextStatus = "failed";
|
||||
completedAt = evaluatedAt;
|
||||
} else if (nextFrontier.length === 0) {
|
||||
nextStatus = "completed";
|
||||
completedAt = evaluatedAt;
|
||||
@@ -245,11 +277,12 @@ export class WorkflowProjector {
|
||||
closedPacket: input.currentPacketId,
|
||||
nextPacket: nextQitemId,
|
||||
resultNote: input.resultNote ?? null,
|
||||
blockedOn: input.blockedOn ?? null,
|
||||
},
|
||||
completedAt,
|
||||
});
|
||||
|
||||
// 5. Persist workflow events within the same txn.
|
||||
// 6. Persist workflow events within the same txn.
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.step_closed",
|
||||
@@ -260,8 +293,7 @@ export class WorkflowProjector {
|
||||
priorQitemId: input.currentPacketId,
|
||||
}),
|
||||
);
|
||||
if (createdNext) {
|
||||
// Persist queue.created event (created within QueueRepository.createWithinTransaction).
|
||||
if (createdNext && nextStep && resolvedNextOwner) {
|
||||
persistedEvents.push(createdNext.persistedEvent);
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
@@ -269,7 +301,7 @@ export class WorkflowProjector {
|
||||
instanceId: instance.instanceId,
|
||||
nextQitemId: createdNext.qitemId,
|
||||
nextOwner: createdNext.destinationSession,
|
||||
nextStepId: nextStep!.id,
|
||||
nextStepId: nextStep.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -281,6 +313,15 @@ export class WorkflowProjector {
|
||||
workflowName: instance.workflowName,
|
||||
}),
|
||||
);
|
||||
} else if (nextStatus === "failed") {
|
||||
persistedEvents.push(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "workflow.failed",
|
||||
instanceId: instance.instanceId,
|
||||
workflowName: instance.workflowName,
|
||||
reason: input.resultNote ?? "workflow_step_failed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -358,6 +399,85 @@ function resolveDefaultOwner(spec: WorkflowSpec, step: WorkflowStepSpec): string
|
||||
return targets[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 fix (guard blocker 1): translate a workflow exit into the Phase A
|
||||
* queue closure shape (state + closure_reason + closure_target +
|
||||
* handed_off_to / blocked_on metadata). Phase A's hot-potato closure
|
||||
* validation enforces the rules:
|
||||
* - state=done requires closure_reason from CLOSURE_REASONS
|
||||
* - handed_off_to / blocked_on / escalation also require closure_target
|
||||
*
|
||||
* Mappings:
|
||||
* handoff → state=handed-off, closure_reason=handed_off_to,
|
||||
* closure_target+handed_off_to=<next-owner>
|
||||
* waiting → state=blocked, closure_reason=blocked_on,
|
||||
* closure_target+blocked_on=<blocker>
|
||||
* done → state=done, closure_reason=no-follow-on
|
||||
* failed → state=done, closure_reason=denied (workflow status=failed
|
||||
* is set separately on the instance row)
|
||||
*/
|
||||
function workflowExitToQueueClosure(
|
||||
input: ProjectStepInput,
|
||||
resolvedNextOwner: string | null,
|
||||
): {
|
||||
state: "handed-off" | "blocked" | "done";
|
||||
closureReason: string;
|
||||
closureTarget: string | null;
|
||||
handedOffTo?: string;
|
||||
blockedOn?: string;
|
||||
transitionNote: string;
|
||||
} {
|
||||
switch (input.exit) {
|
||||
case "handoff": {
|
||||
if (!resolvedNextOwner) {
|
||||
// Defense-in-depth — projector validates this earlier and throws
|
||||
// next_owner_unresolved, so this branch is unreachable in practice.
|
||||
throw new WorkflowProjectorError(
|
||||
"next_owner_unresolved",
|
||||
"handoff exit requires a resolved next owner before queue closure",
|
||||
);
|
||||
}
|
||||
return {
|
||||
state: "handed-off",
|
||||
closureReason: "handed_off_to",
|
||||
closureTarget: resolvedNextOwner,
|
||||
handedOffTo: resolvedNextOwner,
|
||||
transitionNote: `workflow handoff to ${resolvedNextOwner}`,
|
||||
};
|
||||
}
|
||||
case "waiting": {
|
||||
const blocker = input.blockedOn ?? "external-gate";
|
||||
return {
|
||||
state: "blocked",
|
||||
closureReason: "blocked_on",
|
||||
closureTarget: blocker,
|
||||
blockedOn: blocker,
|
||||
transitionNote: `workflow waiting on ${blocker}`,
|
||||
};
|
||||
}
|
||||
case "done": {
|
||||
return {
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
closureTarget: null,
|
||||
transitionNote: input.resultNote
|
||||
? `workflow done: ${input.resultNote}`
|
||||
: "workflow done",
|
||||
};
|
||||
}
|
||||
case "failed": {
|
||||
return {
|
||||
state: "done",
|
||||
closureReason: "denied",
|
||||
closureTarget: input.resultNote ?? "workflow_step_failed",
|
||||
transitionNote: input.resultNote
|
||||
? `workflow failed: ${input.resultNote}`
|
||||
: "workflow failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workflowHandoffBody(input: {
|
||||
spec: WorkflowSpec;
|
||||
instance: WorkflowInstance;
|
||||
|
||||
@@ -105,6 +105,84 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
expect(entryItem?.state).toBe("pending");
|
||||
});
|
||||
|
||||
// R1 fix (guard blocker 1): project handoff persists Phase A queue
|
||||
// closure metadata (closure_reason, closure_target, handed_off_to) +
|
||||
// appends queue_transitions row + emits queue.updated event.
|
||||
it("project(handoff) persists Phase A queue closure metadata + transition row + queue.updated event", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
resultNote: "produced",
|
||||
});
|
||||
// Phase A queue closure contract preserved.
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
expect(closedItem?.state).toBe("handed-off");
|
||||
expect(closedItem?.closureReason).toBe("handed_off_to");
|
||||
expect(closedItem?.closureTarget).toBe("reviewer@rig");
|
||||
expect(closedItem?.handedOffTo).toBe("reviewer@rig");
|
||||
// queue_transitions row appended.
|
||||
const transitions = db
|
||||
.prepare(`SELECT * FROM queue_transitions WHERE qitem_id = ? ORDER BY ts DESC`)
|
||||
.all(inst.entryQitemId) as Array<{ state: string; closure_reason: string | null; closure_target: string | null }>;
|
||||
expect(transitions.find((t) => t.state === "handed-off")).toBeDefined();
|
||||
expect(transitions.find((t) => t.state === "handed-off")?.closure_reason).toBe("handed_off_to");
|
||||
// queue.updated event emitted.
|
||||
expect(events.find((e) => e.type === "queue.updated")).toBeDefined();
|
||||
// Workflow events too.
|
||||
expect(events.find((e) => e.type === "workflow.step_closed")).toBeDefined();
|
||||
expect(events.find((e) => e.type === "workflow.next_qitem_projected")).toBeDefined();
|
||||
expect(projected.nextStepId).toBe("review");
|
||||
});
|
||||
|
||||
it("project(done) without exposed closure overrides defaults closure_reason to no-follow-on", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
expect(closedItem?.state).toBe("done");
|
||||
expect(closedItem?.closureReason).toBe("no-follow-on");
|
||||
expect(events.find((e) => e.type === "queue.updated")).toBeDefined();
|
||||
});
|
||||
|
||||
it("project(waiting) uses Phase A blocked state + closure_reason=blocked_on + blocked_on column", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "waiting",
|
||||
actorSession: "producer@rig",
|
||||
blockedOn: "external-gate-x",
|
||||
});
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
expect(closedItem?.state).toBe("blocked");
|
||||
expect(closedItem?.closureReason).toBe("blocked_on");
|
||||
expect(closedItem?.closureTarget).toBe("external-gate-x");
|
||||
expect(closedItem?.blockedOn).toBe("external-gate-x");
|
||||
});
|
||||
|
||||
it("project(handoff) closes current packet AND creates next-step packet IN ONE TRANSACTION; emits step_closed + next_qitem_projected", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
@@ -255,12 +333,17 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
}
|
||||
});
|
||||
|
||||
it("project(waiting) sets status=waiting + frontier preserves the packet", async () => {
|
||||
// R1 fix (guard blocker 2b): project(waiting) PRESERVES the closed
|
||||
// packet on currentFrontier so workflow-keepalive can still resolve
|
||||
// the owner. The blocker bug was: removing the closed packet left
|
||||
// single-packet waiting workflows with no owner to wake.
|
||||
it("project(waiting) sets status=waiting AND preserves the closed packet on currentFrontier (workflow-keepalive resolvability)", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
expect(inst.instance.currentFrontier).toEqual([inst.entryQitemId]);
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
@@ -270,6 +353,43 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
});
|
||||
expect(projected.instance.status).toBe("waiting");
|
||||
expect(projected.nextQitemId).toBeNull();
|
||||
// Phase A queue closure shape on the blocked packet.
|
||||
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe("blocked");
|
||||
// FRONTIER PRESERVED — workflow-keepalive can still wake the owner.
|
||||
expect(projected.instance.currentFrontier).toEqual([inst.entryQitemId]);
|
||||
// Re-fetch to confirm the persisted instance row reflects the same.
|
||||
const reread = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
|
||||
expect(reread.currentFrontier).toEqual([inst.entryQitemId]);
|
||||
});
|
||||
|
||||
// R1 fix (guard blocker 2a): exit=failed sets workflow status=failed
|
||||
// (not completed) AND emits workflow.failed (not workflow.completed).
|
||||
it("project(failed) sets workflow status=failed AND emits workflow.failed (NOT completed)", async () => {
|
||||
const events: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "failed",
|
||||
actorSession: "producer@rig",
|
||||
resultNote: "could not produce artifact",
|
||||
});
|
||||
expect(projected.instance.status).toBe("failed");
|
||||
expect(events.find((e) => e.type === "workflow.failed")).toBeDefined();
|
||||
expect(events.find((e) => e.type === "workflow.completed")).toBeUndefined();
|
||||
// Phase A queue closure: state=done with closure_reason=denied.
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
expect(closedItem?.state).toBe("done");
|
||||
expect(closedItem?.closureReason).toBe("denied");
|
||||
// Persisted failed event carries the reason.
|
||||
const failedEvent = events.find((e) => e.type === "workflow.failed") as
|
||||
| { type: "workflow.failed"; reason: string }
|
||||
| undefined;
|
||||
expect(failedEvent?.reason).toBe("could not produce artifact");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user