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:
mvschwarzandClaude Opus 4.7 committed 2026-05-03 07:57:37 -07:00
1 parent 55bdcbb73c
commit bf28cc2f7e
3 files changed
+353 -61

No files matched your search

+90 -38
View File
@@ -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 {
+142 -22
View File
@@ -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;
+121 -1
View File
@@ -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");
});
});