mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
fix(daemon): PL-004 Phase D R2 — durable current-step binding + allowed_exits enforcement at projection
Repairs guard BLOCK on R1 commit 4a66e3c:
Blocker 1 — waiting/resume semantics still wrong (trail-based inference):
R1 preserved the closed packet on currentFrontier when exit=waiting,
but the next project() call inferred current step from "last trail row
+ 1," skipping the parked step. POC contract is to extract step_id
from the current packet binding, not from trail order.
R2 fix — durable current_step_id binding on workflow_instances:
- Migration 034_workflow_instances extended IN PLACE with
current_step_id TEXT column. (In-place because Phase D has not
been promoted past base 1ab394e; same pattern guard accepted for
Phase C R1's actionable/last_actionable_at columns on 031.)
- WorkflowInstance.currentStepId field; rowToInstance reads it.
- WorkflowInstanceStore.create() accepts currentStepId; updateFrontier
accepts currentStepId of "preserve" | "clear" | <stepId>.
- WorkflowRuntime.instantiate() sets current_step_id to the entry
step at instance creation time.
- WorkflowProjector.project() replaces inferCurrentStep (trail-based)
with resolveCurrentStep (reads instance.currentStepId). Falls back
to entry step ONLY when current_step_id is null (defense-in-depth).
- Frontier-update transitions:
handoff → current_step_id = nextStep.id
waiting → current_step_id preserved (resume on same packet
still on same step)
done / failed → current_step_id cleared (terminal)
Resume regression added (canonical "drive→park→resume on same id" per
skill lesson feedback_resume_after_parked_state_regression):
- "R2 resume regression: handoff → waiting on step N → done on same
qitem closes step N (not N+1)": instantiate three-step workflow,
handoff produce → review, waiting on review qitem (status=waiting,
current_step_id="review", frontier preserves review qitem), then
done on the SAME review qitem. Asserts: trail's last entry has
stepId="review" (NOT "finalize"); workflow ends as completed
because no successor projected; NO trail row written for "finalize"
(would have indicated a step skip).
Blocker 2 — projector did not enforce step.allowed_exits at runtime:
R1 left allowed_exits as a validation-only decoration. The R1 done test
encoded the wrong behavior (projecting exit=done on a step that only
allows handoff and asserting closure). R2 makes allowed_exits a runtime
contract that fires BEFORE any side effect.
R2 fix:
- WorkflowProjector.project() validates input.exit ∈
currentStep.allowed_exits when the step declares allowed_exits.
Throws WorkflowProjectorError {code: "exit_not_allowed",
details: {stepId, attemptedExit, allowedExits}} BEFORE queue close,
trail append, frontier update, or event persist. No side effects
on rejection.
R2 regression tests added:
- "R2 allowed_exits enforcement (positive): exit included in
step.allowed_exits succeeds": handoff on a step that allows handoff
succeeds and returns the next step.
- "R2 allowed_exits enforcement (negative): disallowed exit throws
exit_not_allowed with NO side effects": done on a step that allows
only handoff throws WorkflowProjectorError(exit_not_allowed) with
structured details; queue state, instance frontier, current_step_id,
trail count, and event count are ALL unchanged after the rejection.
Test fixture changes:
- Added PERMISSIVE_SPEC fixture for closure-shape unit tests
(project(done), project(waiting), project(failed), instance-not-active
rejection). The main 3-step SPEC has allowed_exits=[handoff] on the
entry step; tests that deliberately exercise non-handoff closures on
the entry packet now use PERMISSIVE_SPEC's "act" step which allows
all four exits. R1 tests that incorrectly assumed the produce step
accepted any exit are corrected.
Skill lesson filed at memory/feedback_resume_after_parked_state_regression.md
per guard's call-out: every preservation repair (waiting, blocked, parked,
restore-resumed) needs a "drive→park→resume on same id" regression that
asserts the resumed action targets the same logical entity that was
parked, not an inferred-from-side-effect successor.
Verification:
- tsc clean for daemon and cli.
- Per-package vitest baseline under explicit OPENRIG_HOME +
OPENRIG_DB=:memory: temp env:
- packages/daemon: 2559 / 2559 pass (was 2556 in Phase D R1; +3
R2 regressions: resume + allowed_exits positive + allowed_exits
negative).
- packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
CLI failures unchanged.
Boundaries preserved (R2):
- Worktree-only on phase-d-coordination-primitive-system; main untouched.
- Migration 034 extended in place (Phase D unpromoted; matches
accepted Phase C R1 in-place 031 extension pattern).
- No-touch surfaces unchanged: Phase A non-extension queue surfaces
beyond the orch-ratified Phase D extension points, Phase B
project/classifier/view, Lifecycle v2, migrations 001-033, 035-036,
POC source.
- workflow-keepalive still reads ONLY from SQLite.
- Workflow runtime PROJECTS on closure; does NOT GATE closure (Phase A
hot-potato strict-rejection remains the closure authority).
- allowed_exits enforcement at projection complements (does not
replace) the validator's spec-level allowed_exits check.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
4a66e3c8a5
commit
b699f394dc
6 files changed
+365
-38
No files matched your search
@@ -8,6 +8,15 @@ import type { Migration } from "../migrate.js";
|
||||
* filesystem reconciliation. The current_frontier_json column holds the
|
||||
* array of qitem_ids that are the active step packets at any given time.
|
||||
*
|
||||
* R2 fix (guard blocker 1): durable current-step binding via
|
||||
* `current_step_id`. Replaces trail-based "last_step + 1" inference,
|
||||
* which produced wrong results for reused-frontier packets (e.g.,
|
||||
* waiting → resume on same packet would skip a step). Mirrors POC
|
||||
* which extracts step_id from the current packet binding rather than
|
||||
* inferring from trail order. v1 supports a single active frontier
|
||||
* packet; multi-frontier (parallel branches) is graduation and would
|
||||
* need a packet→step map.
|
||||
*
|
||||
* Status enum (matches POC + PRD):
|
||||
* - active: instance has at least one frontier packet in-flight
|
||||
* - waiting: instance has a frontier packet blocked on an external gate
|
||||
@@ -41,6 +50,7 @@ export const workflowInstancesSchema: Migration = {
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
current_frontier_json TEXT NOT NULL DEFAULT '[]',
|
||||
current_step_id TEXT,
|
||||
hop_count INTEGER NOT NULL DEFAULT 0,
|
||||
fallback_synthesis TEXT,
|
||||
last_continuation_decision_json TEXT,
|
||||
|
||||
@@ -20,6 +20,7 @@ interface InstanceRow {
|
||||
created_at: string;
|
||||
status: string;
|
||||
current_frontier_json: string;
|
||||
current_step_id: string | null;
|
||||
hop_count: number;
|
||||
fallback_synthesis: string | null;
|
||||
last_continuation_decision_json: string | null;
|
||||
@@ -31,6 +32,8 @@ export interface CreateWorkflowInstanceInput {
|
||||
workflowVersion: string;
|
||||
createdBySession: string;
|
||||
initialFrontier?: string[];
|
||||
/** R2: durable current-step binding set at instantiate time. */
|
||||
currentStepId?: string;
|
||||
}
|
||||
|
||||
export class WorkflowInstanceError extends Error {
|
||||
@@ -58,8 +61,8 @@ export class WorkflowInstanceStore {
|
||||
.prepare(
|
||||
`INSERT INTO workflow_instances (
|
||||
instance_id, workflow_name, workflow_version, created_by_session,
|
||||
created_at, status, current_frontier_json, hop_count
|
||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, 0)`,
|
||||
created_at, status, current_frontier_json, current_step_id, hop_count
|
||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 0)`,
|
||||
)
|
||||
.run(
|
||||
instanceId,
|
||||
@@ -68,6 +71,7 @@ export class WorkflowInstanceStore {
|
||||
input.createdBySession,
|
||||
createdAt,
|
||||
JSON.stringify(frontier),
|
||||
input.currentStepId ?? null,
|
||||
);
|
||||
return this.getByIdOrThrow(instanceId);
|
||||
}
|
||||
@@ -122,19 +126,38 @@ export class WorkflowInstanceStore {
|
||||
lastContinuationDecision?: Record<string, unknown> | null;
|
||||
fallbackSynthesis?: string | null;
|
||||
completedAt?: string | null;
|
||||
/**
|
||||
* R2: explicit next current_step_id. When provided, OVERWRITES
|
||||
* the column (including to NULL by passing the empty string for
|
||||
* "clear"). When omitted, current_step_id is preserved (the
|
||||
* frontier packet is reused, e.g., on waiting). To clear pass
|
||||
* the symbol "clear-current-step" (typed as the literal below).
|
||||
*/
|
||||
currentStepId?: string | "preserve" | "clear";
|
||||
} = {},
|
||||
): void {
|
||||
const setHop = opts.bumpHopCount ? "hop_count = hop_count + 1, " : "";
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE workflow_instances SET
|
||||
${setHop}status = ?, current_frontier_json = ?,
|
||||
let currentStepClause = "";
|
||||
let currentStepValue: string | null | undefined;
|
||||
if (opts.currentStepId === "preserve" || opts.currentStepId === undefined) {
|
||||
currentStepClause = "";
|
||||
currentStepValue = undefined;
|
||||
} else if (opts.currentStepId === "clear") {
|
||||
currentStepClause = "current_step_id = NULL, ";
|
||||
} else {
|
||||
currentStepClause = "current_step_id = ?, ";
|
||||
currentStepValue = opts.currentStepId;
|
||||
}
|
||||
const sql = `UPDATE workflow_instances SET
|
||||
${setHop}${currentStepClause}status = ?, current_frontier_json = ?,
|
||||
last_continuation_decision_json = COALESCE(?, last_continuation_decision_json),
|
||||
fallback_synthesis = COALESCE(?, fallback_synthesis),
|
||||
completed_at = COALESCE(?, completed_at)
|
||||
WHERE instance_id = ?`,
|
||||
)
|
||||
.run(
|
||||
WHERE instance_id = ?`;
|
||||
const stmt = this.db.prepare(sql);
|
||||
if (currentStepValue !== undefined) {
|
||||
stmt.run(
|
||||
currentStepValue,
|
||||
nextStatus,
|
||||
JSON.stringify(nextFrontier),
|
||||
opts.lastContinuationDecision ? JSON.stringify(opts.lastContinuationDecision) : null,
|
||||
@@ -142,6 +165,16 @@ export class WorkflowInstanceStore {
|
||||
opts.completedAt ?? null,
|
||||
instanceId,
|
||||
);
|
||||
} else {
|
||||
stmt.run(
|
||||
nextStatus,
|
||||
JSON.stringify(nextFrontier),
|
||||
opts.lastContinuationDecision ? JSON.stringify(opts.lastContinuationDecision) : null,
|
||||
opts.fallbackSynthesis ?? null,
|
||||
opts.completedAt ?? null,
|
||||
instanceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +187,7 @@ function rowToInstance(row: InstanceRow): WorkflowInstance {
|
||||
createdAt: row.created_at,
|
||||
status: row.status as WorkflowInstanceStatus,
|
||||
currentFrontier: JSON.parse(row.current_frontier_json) as string[],
|
||||
currentStepId: row.current_step_id,
|
||||
hopCount: row.hop_count,
|
||||
fallbackSynthesis: row.fallback_synthesis,
|
||||
lastContinuationDecision: row.last_continuation_decision_json
|
||||
|
||||
@@ -133,12 +133,39 @@ export class WorkflowProjector {
|
||||
{ currentPacketId: input.currentPacketId },
|
||||
);
|
||||
}
|
||||
const currentStep = inferCurrentStep(spec, instance, this.trailLog);
|
||||
// R2 fix (guard blocker 1): read durable current_step_id from instance
|
||||
// rather than inferring "last_trail_step + 1". Reused-frontier packets
|
||||
// (waiting then resume) now correctly resume the SAME step.
|
||||
const currentStep = resolveCurrentStep(spec, instance);
|
||||
if (!currentStep) {
|
||||
throw new WorkflowProjectorError(
|
||||
"current_step_unknown",
|
||||
`workflow instance ${instance.instanceId} cannot infer the current step from spec ${spec.id}@${spec.version} + trail; check that the trail is consistent and that the spec has not changed shape mid-instance`,
|
||||
{ instanceId: instance.instanceId, frontier: instance.currentFrontier },
|
||||
`workflow instance ${instance.instanceId} has no resolvable current step (current_step_id=${JSON.stringify(instance.currentStepId)}). The instance may be in a terminal state, or the spec ${spec.id}@${spec.version} may have changed shape mid-instance.`,
|
||||
{ instanceId: instance.instanceId, currentStepId: instance.currentStepId, frontier: instance.currentFrontier },
|
||||
);
|
||||
}
|
||||
|
||||
// R2 fix (guard blocker 2): enforce currentStep.allowed_exits at
|
||||
// projection time. POC contract: an exit not in the step's
|
||||
// allowed_exits is rejected with a structured error and NO state
|
||||
// mutation. Validation happens BEFORE any side effect (queue close,
|
||||
// trail append, frontier update, event persist). When allowed_exits
|
||||
// is omitted on the step, no enforcement (operator opted out at
|
||||
// spec authoring time).
|
||||
if (
|
||||
currentStep.allowed_exits &&
|
||||
currentStep.allowed_exits.length > 0 &&
|
||||
!currentStep.allowed_exits.includes(input.exit)
|
||||
) {
|
||||
throw new WorkflowProjectorError(
|
||||
"exit_not_allowed",
|
||||
`step "${currentStep.id}" allows exits ${JSON.stringify(currentStep.allowed_exits)}; got "${input.exit}". Either close with a permitted exit, or amend the spec.`,
|
||||
{
|
||||
instanceId: instance.instanceId,
|
||||
stepId: currentStep.id,
|
||||
attemptedExit: input.exit,
|
||||
allowedExits: currentStep.allowed_exits,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,6 +296,19 @@ export class WorkflowProjector {
|
||||
} else {
|
||||
nextStatus = "active";
|
||||
}
|
||||
// R2 fix: set durable current_step_id transition.
|
||||
// handoff → next step
|
||||
// waiting → preserve (resume on same packet still on same step)
|
||||
// done / failed → clear (terminal)
|
||||
let currentStepIdUpdate: "preserve" | "clear" | string;
|
||||
if (input.exit === "handoff" && nextStep) {
|
||||
currentStepIdUpdate = nextStep.id;
|
||||
} else if (input.exit === "waiting") {
|
||||
currentStepIdUpdate = "preserve";
|
||||
} else {
|
||||
// done or failed
|
||||
currentStepIdUpdate = "clear";
|
||||
}
|
||||
this.instanceStore.updateFrontier(instance.instanceId, nextFrontier, nextStatus, {
|
||||
bumpHopCount: input.exit === "handoff",
|
||||
lastContinuationDecision: {
|
||||
@@ -278,8 +318,10 @@ export class WorkflowProjector {
|
||||
nextPacket: nextQitemId,
|
||||
resultNote: input.resultNote ?? null,
|
||||
blockedOn: input.blockedOn ?? null,
|
||||
currentStep: currentStep.id,
|
||||
},
|
||||
completedAt,
|
||||
currentStepId: currentStepIdUpdate,
|
||||
});
|
||||
|
||||
// 6. Persist workflow events within the same txn.
|
||||
@@ -354,27 +396,36 @@ export class WorkflowProjector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the current step from the trail. v1 uses a simple rule:
|
||||
* - If trail is empty → entry step is the current step.
|
||||
* - Else → the step after the most recently-trailed step.
|
||||
* R2 fix (guard blocker 1): resolve the current step from durable
|
||||
* instance.currentStepId, NOT from trail order. The previous trail-based
|
||||
* inference produced wrong results for reused-frontier packets:
|
||||
* waiting → resume on same packet would skip a step, because the
|
||||
* waiting close wrote a trail row that the next inference treated as
|
||||
* "last step + 1".
|
||||
*
|
||||
* Multi-active-frontier instances (parallel steps) are a graduation;
|
||||
* v1 always has at most one active frontier packet at a time.
|
||||
* Falls back to the entry step ONLY when current_step_id is null
|
||||
* (instantiate didn't set it; defense-in-depth, not the production
|
||||
* path).
|
||||
*
|
||||
* Multi-active-frontier instances (parallel steps) remain a graduation;
|
||||
* v1 supports a single active frontier packet, so a single
|
||||
* current_step_id column suffices.
|
||||
*/
|
||||
function inferCurrentStep(
|
||||
function resolveCurrentStep(
|
||||
spec: WorkflowSpec,
|
||||
instance: WorkflowInstance,
|
||||
trailLog: WorkflowStepTrailLog,
|
||||
): WorkflowStepSpec | null {
|
||||
const trail = trailLog.listForInstance(instance.instanceId, 1);
|
||||
if (trail.length === 0) {
|
||||
const entryRole = spec.entry?.role;
|
||||
return spec.steps[0] ?? (entryRole ? spec.steps.find((s) => s.actor_role === entryRole) ?? null : null) ?? null;
|
||||
if (instance.currentStepId) {
|
||||
return spec.steps.find((s) => s.id === instance.currentStepId) ?? null;
|
||||
}
|
||||
const lastStepId = trail[0]!.stepId;
|
||||
const idx = spec.steps.findIndex((s) => s.id === lastStepId);
|
||||
if (idx === -1) return null;
|
||||
return spec.steps[idx + 1] ?? null;
|
||||
// Defense-in-depth: instance lacks current_step_id. Fall back to
|
||||
// entry step (matches behavior of pre-R2 instances if any survive).
|
||||
const entryRole = spec.entry?.role;
|
||||
return (
|
||||
spec.steps[0] ??
|
||||
(entryRole ? spec.steps.find((s) => s.actor_role === entryRole) ?? null : null) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,6 +142,10 @@ export class WorkflowRuntime {
|
||||
workflowVersion: specRow.version,
|
||||
createdBySession: input.createdBySession,
|
||||
initialFrontier: [],
|
||||
// R2 fix: set durable current_step_id at instantiate so the
|
||||
// projector resolves the correct step on the first project()
|
||||
// call without any trail-based inference.
|
||||
currentStepId: entryStep.id,
|
||||
});
|
||||
instanceId = instance.instanceId;
|
||||
|
||||
|
||||
@@ -81,6 +81,15 @@ export interface WorkflowInstance {
|
||||
status: WorkflowInstanceStatus;
|
||||
/** qitem_ids that are currently active step packets. */
|
||||
currentFrontier: string[];
|
||||
/**
|
||||
* R2 fix: durable current-step binding (which step the active
|
||||
* frontier packet represents). Set on instantiate (entry step) and
|
||||
* on handoff (next step). Cleared on terminal closure. Read by
|
||||
* WorkflowProjector instead of inferring from trail order — fixes
|
||||
* the waiting-resume "skip a step" bug. v1 supports a single active
|
||||
* frontier packet.
|
||||
*/
|
||||
currentStepId: string | null;
|
||||
hopCount: number;
|
||||
fallbackSynthesis: string | null;
|
||||
lastContinuationDecision: Record<string, unknown> | null;
|
||||
|
||||
@@ -51,6 +51,44 @@ const SPEC = `workflow:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
- failed
|
||||
`;
|
||||
|
||||
// Permissive spec for testing closure-shape variants (done, waiting, failed)
|
||||
// without walking through the whole workflow. The entry step allows all four
|
||||
// exit kinds; otherwise R2's allowed_exits enforcement (correctly) rejects
|
||||
// non-handoff closures on a step that only declares handoff.
|
||||
const PERMISSIVE_SPEC = `workflow:
|
||||
id: pd-permissive
|
||||
version: 1
|
||||
objective: closure-shape unit tests
|
||||
entry:
|
||||
role: anyone
|
||||
roles:
|
||||
anyone:
|
||||
preferred_targets:
|
||||
- anyone@rig
|
||||
next:
|
||||
preferred_targets:
|
||||
- next@rig
|
||||
steps:
|
||||
- id: act
|
||||
actor_role: anyone
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
- failed
|
||||
- id: follow
|
||||
actor_role: next
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
- failed
|
||||
`;
|
||||
|
||||
describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scribe LOAD-BEARING)", () => {
|
||||
@@ -60,6 +98,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
let runtime: WorkflowRuntime;
|
||||
let tmp: string;
|
||||
let specPath: string;
|
||||
let permissiveSpecPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
@@ -79,6 +118,8 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
tmp = mkdtempSync(join(tmpdir(), "wf-proj-"));
|
||||
specPath = join(tmp, "spec.yaml");
|
||||
writeFileSync(specPath, SPEC);
|
||||
permissiveSpecPath = join(tmp, "permissive-spec.yaml");
|
||||
writeFileSync(permissiveSpecPath, PERMISSIVE_SPEC);
|
||||
runtime = new WorkflowRuntime({ db, eventBus: bus, queueRepo });
|
||||
});
|
||||
|
||||
@@ -145,7 +186,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
|
||||
it("project(done) without exposed closure overrides defaults closure_reason to no-follow-on", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
specPath: permissiveSpecPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
@@ -155,7 +196,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
});
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
expect(closedItem?.state).toBe("done");
|
||||
@@ -165,7 +206,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
|
||||
it("project(waiting) uses Phase A blocked state + closure_reason=blocked_on + blocked_on column", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
specPath: permissiveSpecPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
@@ -173,7 +214,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "waiting",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
blockedOn: "external-gate-x",
|
||||
});
|
||||
const closedItem = queueRepo.getById(inst.entryQitemId);
|
||||
@@ -308,7 +349,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
|
||||
it("project rejects on completed instance → 409 instance_not_active", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath,
|
||||
specPath: permissiveSpecPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
@@ -316,7 +357,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
});
|
||||
expect(projected.instance.status).toBe("completed");
|
||||
try {
|
||||
@@ -324,7 +365,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
@@ -339,7 +380,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
// 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,
|
||||
specPath: permissiveSpecPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
@@ -348,7 +389,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "waiting",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
blockedOn: "external-gate",
|
||||
});
|
||||
expect(projected.instance.status).toBe("waiting");
|
||||
@@ -362,13 +403,191 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
expect(reread.currentFrontier).toEqual([inst.entryQitemId]);
|
||||
});
|
||||
|
||||
// R2 fix (guard blocker 1): durable current-step binding. After
|
||||
// handoff to step N then waiting on the same packet, resuming with
|
||||
// done/handoff must close step N — NOT step N+1 (the trail-based
|
||||
// bug R1 didn't catch). This is the canonical "resume after parked"
|
||||
// regression per skill lesson feedback_resume_after_parked_state_regression.
|
||||
it("R2 resume regression: handoff → waiting on step N → done on same qitem closes step N (not N+1)", async () => {
|
||||
// Use a 3-step spec where step "review" allows handoff + waiting + done
|
||||
// (so we can park at step 2 and resume).
|
||||
const resumeSpec = `workflow:
|
||||
id: pd-resume-after-waiting
|
||||
version: 1
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@rig
|
||||
reviewer:
|
||||
preferred_targets:
|
||||
- reviewer@rig
|
||||
finalizer:
|
||||
preferred_targets:
|
||||
- finalizer@rig
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- id: review
|
||||
actor_role: reviewer
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
- id: finalize
|
||||
actor_role: finalizer
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- handoff
|
||||
- waiting
|
||||
- done
|
||||
`;
|
||||
const resumeSpecPath = join(tmp, "resume.yaml");
|
||||
writeFileSync(resumeSpecPath, resumeSpec);
|
||||
|
||||
// Step 1: instantiate, then handoff produce → review.
|
||||
const inst = await runtime.instantiate({
|
||||
specPath: resumeSpecPath,
|
||||
rootObjective: "resume regression",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const handed = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
expect(handed.nextStepId).toBe("review");
|
||||
const reviewQitemId = handed.nextQitemId!;
|
||||
|
||||
// current_step_id now bound to "review".
|
||||
let instAfterHandoff = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
|
||||
expect(instAfterHandoff.currentStepId).toBe("review");
|
||||
expect(instAfterHandoff.currentFrontier).toEqual([reviewQitemId]);
|
||||
|
||||
// Step 2: park review on the same qitem.
|
||||
const waited = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: reviewQitemId,
|
||||
exit: "waiting",
|
||||
actorSession: "reviewer@rig",
|
||||
blockedOn: "external-gate",
|
||||
});
|
||||
expect(waited.instance.status).toBe("waiting");
|
||||
// Frontier preserved AND current_step_id still "review" (NOT advanced
|
||||
// to "finalize" by trail order).
|
||||
let instWaiting = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
|
||||
expect(instWaiting.currentFrontier).toEqual([reviewQitemId]);
|
||||
expect(instWaiting.currentStepId).toBe("review");
|
||||
|
||||
// Step 3: resume with done on the SAME packet. Must close step "review",
|
||||
// not "finalize". And the workflow_step_trails should have a "review"
|
||||
// closure (the R1 bug was that trail-based inference would have
|
||||
// resumed as if we were now on "finalize" — wrong).
|
||||
const resumed = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: reviewQitemId,
|
||||
exit: "done",
|
||||
actorSession: "reviewer@rig",
|
||||
resultNote: "review complete",
|
||||
});
|
||||
|
||||
// Last trail entry is for step "review", NOT "finalize".
|
||||
const trail = runtime.trailLog.listForInstance(inst.instance.instanceId);
|
||||
const lastTrail = trail[0]!; // DESC order
|
||||
expect(lastTrail.stepId).toBe("review");
|
||||
expect(lastTrail.closureReason).toBe("done");
|
||||
expect(lastTrail.priorQitemId).toBe(reviewQitemId);
|
||||
|
||||
// The done exit on review with no successor on the frontier means the
|
||||
// workflow is now... actually the spec says "review" can exit with done,
|
||||
// and the next step "finalize" exists but isn't projected because we
|
||||
// chose "done" not "handoff". So the workflow ends here as completed.
|
||||
expect(resumed.instance.status).toBe("completed");
|
||||
expect(resumed.instance.currentFrontier).toEqual([]);
|
||||
|
||||
// Critical: NO trail row was written for "finalize" — the bug would
|
||||
// have either skipped to finalize OR made the closure target the
|
||||
// wrong step.
|
||||
expect(trail.find((t) => t.stepId === "finalize")).toBeUndefined();
|
||||
});
|
||||
|
||||
// R2 fix (guard blocker 2): allowed_exits enforcement positive case.
|
||||
it("R2 allowed_exits enforcement (positive): exit included in step.allowed_exits succeeds", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath, // produce step allows handoff
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
// handoff IS in produce's allowed_exits — must succeed.
|
||||
const projected = await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "handoff",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
expect(projected.nextStepId).toBe("review");
|
||||
});
|
||||
|
||||
// R2 fix (guard blocker 2): allowed_exits enforcement negative case
|
||||
// with rollback/no-side-effect coverage.
|
||||
it("R2 allowed_exits enforcement (negative): disallowed exit throws exit_not_allowed with NO side effects", async () => {
|
||||
const inst = await runtime.instantiate({
|
||||
specPath, // produce step allows handoff ONLY
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
const beforeQueueState = queueRepo.getById(inst.entryQitemId)?.state;
|
||||
const beforeFrontier = inst.instance.currentFrontier;
|
||||
const beforeStepId = inst.instance.currentStepId;
|
||||
const beforeTrailCount = runtime.trailLog.countForInstance(inst.instance.instanceId);
|
||||
const eventsBefore: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => eventsBefore.push(e));
|
||||
|
||||
// done is NOT in produce.allowed_exits — must throw.
|
||||
let threw = false;
|
||||
try {
|
||||
await runtime.project({
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "done",
|
||||
actorSession: "producer@rig",
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
expect(err).toBeInstanceOf(WorkflowProjectorError);
|
||||
const e = err as WorkflowProjectorError;
|
||||
expect(e.code).toBe("exit_not_allowed");
|
||||
expect(e.details?.allowedExits).toEqual(["handoff"]);
|
||||
expect(e.details?.attemptedExit).toBe("done");
|
||||
expect(e.details?.stepId).toBe("produce");
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// NO SIDE EFFECTS: queue state, instance frontier, current_step_id,
|
||||
// trail count, events all unchanged.
|
||||
expect(queueRepo.getById(inst.entryQitemId)?.state).toBe(beforeQueueState);
|
||||
const instAfter = runtime.instanceStore.getByIdOrThrow(inst.instance.instanceId);
|
||||
expect(instAfter.currentFrontier).toEqual(beforeFrontier);
|
||||
expect(instAfter.currentStepId).toBe(beforeStepId);
|
||||
expect(runtime.trailLog.countForInstance(inst.instance.instanceId)).toBe(beforeTrailCount);
|
||||
// No workflow.* events emitted by the rejected projection.
|
||||
expect(eventsBefore.find((e) => e.type === "workflow.step_closed")).toBeUndefined();
|
||||
expect(eventsBefore.find((e) => e.type === "queue.updated")).toBeUndefined();
|
||||
});
|
||||
|
||||
// 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,
|
||||
specPath: permissiveSpecPath,
|
||||
rootObjective: "x",
|
||||
createdBySession: "ops@rig",
|
||||
});
|
||||
@@ -376,7 +595,7 @@ describe("WorkflowProjector + WorkflowRuntime (PL-004 Phase D; transactional-scr
|
||||
instanceId: inst.instance.instanceId,
|
||||
currentPacketId: inst.entryQitemId,
|
||||
exit: "failed",
|
||||
actorSession: "producer@rig",
|
||||
actorSession: "anyone@rig",
|
||||
resultNote: "could not produce artifact",
|
||||
});
|
||||
expect(projected.instance.status).toBe("failed");
|
||||
|
||||
Reference in new issue
Block a user