Skip to content

fix: honest done-vs-error reporting on committed tools + routine retry reconciliation (#506) - #527

Merged
Weegy merged 12 commits into
mainfrom
fix/issue-506-outputtemplate-conflict-check
Jul 27, 2026
Merged

fix: honest done-vs-error reporting on committed tools + routine retry reconciliation (#506)#527
Weegy merged 12 commits into
mainfrom
fix/issue-506-outputtemplate-conflict-check

Conversation

@Weegy

@Weegy Weegy commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #506 — a generic "Etwas ist schief gegangen" (something went wrong) error was shown to the user even when the requested action (e.g. creating a routine) had actually succeeded server-side. This was a false-negative with zero diagnostic value, and in the retry case, one that could nudge the user into creating a genuine duplicate.

Two independent root causes are fixed:

1. Streamed-turn false negative on a committed tool call (middleware/packages/harness-orchestrator/src/orchestrator.ts)

This is the fix for the issue's actual one-click repro (no retry involved). chatStreamInner wraps its whole per-turn iteration loop in a single try/catch. An exception thrown by a later iteration's model call (e.g. the call that generates the natural-language confirmation text) — after an earlier iteration's tool call had already committed a real side effect and yielded a successful tool_result — still produced a bare { type: 'error' } event, discarding the fact the action already happened.

The loop now tracks, generically and tool-agnostically (by tool name only — no per-tool special-casing), which tool(s) committed a successful result this turn. When the catch block is reached with at least one such result recorded, it yields the channel-SDK's existing done event instead — the same event type every ordinary successful turn already uses, so no changes are needed in any channel adapter (including the external, private Teams adapter) — with an honest answer naming the tool(s) that completed and stating the turn itself didn't finish generating a follow-up response. A turn where nothing committed yet (a genuine failure) is unaffected and still yields error.

The emergency done path also calls sessionLogger.log(...), matching every other done-emission site in this function, so a follow-up turn has a transcript record of the commit and won't blindly re-invoke the same tool.

Documented, deliberate tradeoff: this tracking treats any successful tool call as grounds for reporting done, without distinguishing read-only tools from mutating ones. A read-only tool succeeding early in a turn, followed by a later, more consequential tool call silently never running (transient failure), would still report done. This was weighed against narrowing the fix to routine-create only or dropping it entirely, and resolved in favor of the generic behavior — the alternative leaves the reported bug unfixed for every tool except one. See the code comments on committedToolNames and the catch block in orchestrator.ts, and the pinned regression test committedToolReporting.test.ts ("reports done even when a later intended action never ran (accepted tradeoff, see code comment)").

2. Routine-create retry reconciliation (middleware/src/plugins/routines/routineRunner.ts, routineStore.ts)

Defense in depth for the retry path: RoutineRunner.createRoutine previously let a retried create (e.g. after a turn's confirmation was dropped before fix #1 above existed, or simply a caller/model retry) fall through to RoutineNameConflictError, nudging the caller toward retrying under a different name and genuinely duplicating the routine.

It now reconciles: on a name conflict (or proactively, before the per-user quota check — a retry from a user already at quota was previously rejected with the wrong exception type before ever reaching reconciliation), it looks up the existing row via RoutineStore.getByName (new) and, if that row is active and matches the request field-by-field — cron/prompt/channel/timeoutMs/outputTemplate/conversationRef, each compared with the same defaulting the store itself applies (?? 600_000, ?? null, ?? {}) — returns that row instead of raising. A conflict against genuinely different fields, or against a paused/inactive same-name row, still raises RoutineNameConflictError — those are real, separate collisions, not the caller's own in-flight retry.

Out of scope (documented in docs/CHANGELOG.md)

  • Threading a request/trace correlation id through routine-turn error responses end-to-end (the issue's secondary, explicitly-optional ask) — would need a new field on the shared ChatTurnInput/ChatTurnResult contract (@omadia/channel-sdk) plus support in every channel adapter. Fix chore(deps,ci): Bump docker/build-push-action from 6 to 7 #1 above resolves the practical symptom anyway: a successfully-committed action is no longer reported as an opaque failure at all.
  • The literal error wording shown in Teams ("Etwas ist schief gegangen …") lives in the external, private Teams-channel adapter plugin (omadia-byte5-plugins), outside this repo.

Verification

cd middleware
npm run build -w @omadia/orchestrator   # clean
npm run lint                            # 0 errors (1 pre-existing, unrelated warning)
npm run typecheck                       # clean across all workspace packages
node --import tsx --test --test-reporter=spec \
  test/routineRunner.test.ts test/hrRoutineTemplate.integration.test.ts \
  test/manageRoutineTool.test.ts test/orchestrator/streamingRetry.test.ts \
  test/orchestrator/loopGuard.test.ts test/orchestrator/parallelTool.test.ts \
  test/orchestrator/directLine.test.ts test/orchestrator/promptMaskPipeline.test.ts \
  test/orchestrator/turnHooks.test.ts test/orchestratorDispatcher.test.ts \
  test/orchestrator/committedToolReporting.test.ts
# 131 pass, 0 fail

Closes #506


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 12 commits July 27, 2026 08:40
createRoutine committed the DB row before the turn's confirmation was
delivered and only rolled back on scheduler-registration failure. A
retry after the confirmation leg dropped (channel timeout, dropped
response) hit the (tenant, user, name) unique constraint and surfaced
as a bare RoutineNameConflictError — a false negative with no
diagnostic value that pushed the caller toward creating an actual
duplicate under a different name.

createRoutine now catches that conflict, looks up the row that
already won the race (RoutineStore.getByName, new), and returns it
when cron/prompt/channel/timeoutMs match what was just requested —
the earlier call already succeeded, so the retry now reports success
too. A conflict with genuinely different fields still raises
RoutineNameConflictError as before.

Threading a correlation/trace id through routine-turn error responses
end-to-end, and the Teams-side wording, remain out of scope (see
docs/CHANGELOG.md entry and PR description).

Closes #506
Issue #506's reconciliation in RoutineRunner.createRoutine matched a
retried create against ANY existing row with the same (tenant, user,
name) whose cron/prompt/channel/timeoutMs were identical, regardless
of that row's status. A paused same-name row would silently be
returned as if the create had succeeded, with no active schedule ever
registered.

Reconciliation now additionally requires existing.status === 'active'
before treating the conflict as the caller's own in-flight retry. A
paused/inactive same-name collision still raises
RoutineNameConflictError, as a genuine, separate name collision
should. Adds unit test coverage for the paused-existing-row case and
documents (in-code and in the changelog) the decision not to
additionally gate on existing.createdAt recency.
isSameRoutineRequest() compared only cron/prompt/channel/timeoutMs when
deciding whether a name-conflicting create is the same request as an
existing active row. It omitted outputTemplate — an independently-
settable object field on both Routine and CreateRoutineInput (Phase C
structured-output templates).

Concrete failing case: a routine is created active with one
outputTemplate, then createRoutine is called again for the same
tenant/user/name/cron/prompt/channel/timeoutMs but a DIFFERENT
outputTemplate. The unique-name constraint fires, getByName() finds
the active row, and isSameRoutineRequest() returned true because
outputTemplate wasn't checked — the call silently returned the OLD
row, discarding the caller's new template while reporting success.
That is exactly the class of silent-wrong-result bug issue #506 was
filed to eliminate, on a field the fix's own comparison forgot.

isSameRoutineRequest now compares outputTemplate via node:util's
isDeepStrictEqual (it is an object, so reference/=== equality is not
sufficient). Adds a unit test asserting a create that differs only in
outputTemplate still raises RoutineNameConflictError, and a companion
test asserting a structurally-identical-but-distinct-object
outputTemplate still reconciles as a true retry.

Closes #506
A user already at maxActivePerUser who retries an identical createRoutine
call (the exact issue #506 scenario — their turn's confirmation was
dropped) was rejected by the quota check before the retry ever reached
the RoutineNameConflictError reconciliation added earlier on this branch,
resurfacing the false-negative as RoutineQuotaExceededError instead.

createRoutine now looks up the existing row by (tenant, userId, name)
before the quota check and before calling store.create(). If it is
active and matches the request, it is returned immediately — no new row,
no quota check. A genuinely new routine name still goes through the
quota gate as before. The store.create() catch-block reconciliation is
unchanged; it remains the race-safety net for a concurrent create
between this lookup and the insert.

Adds two tests: a same-request retry at capacity reconciles without
throwing RoutineQuotaExceededError, and a genuinely new routine name at
capacity still throws it.
RoutineRunner.createRoutine now calls store.getByName unconditionally
(both proactively before the quota check and in the conflict-
reconciliation catch block), but hrRoutineTemplate.integration.test.ts
defines its own hand-rolled InMemoryRoutineStore that lacked the
method. Its runner.createRoutine calls would throw
'this.store.getByName is not a function' at runtime.

Add getByName mirroring the semantics of the real
RoutineStore.getByName: match on tenant + userId + name, return null
if none exists.
isSameRoutineRequest excluded conversationRef from its equality check,
reasoning that it was a delivery-mechanism detail the caller doesn't
control byte-for-byte. That's wrong for the cold-start outreach path:
ManageRoutineTool.handleCreate resolves conversationRef from
targetEmail via buildEmailColdStartTarget before calling
createRoutine, so conversationRef IS caller-specified there.

A create for a new targetEmail that otherwise matches an existing
active routine (same tenant/user/name/cron/prompt/channel/timeoutMs/
outputTemplate) would silently reconcile to the existing row and
report success, while the new recipient never gets set up and the
routine keeps messaging the original one.

Add conversationRef to the isSameRoutineRequest comparison via
isDeepStrictEqual (same pattern as outputTemplate — buildEmailColdStartTarget
resolves deterministically per email, so deep equality correctly
distinguishes a true retry from a different-recipient request) and
update the doc comment to drop the now-incorrect exclusion rationale.
chatStreamInner wraps its whole per-turn iteration loop in one try/catch,
so an exception in a LATER iteration's model call (e.g. generating the
confirmation) discarded an EARLIER iteration's already-committed tool
result behind a bare `error` event -- the issue's actual one-click
repro, not just the retry case fixed earlier on this branch.

Track (generically, by tool name only) whether at least one tool_result
succeeded this turn. If the catch block is reached with a committed
result recorded, yield `done` with an honest answer naming the tool(s)
that completed, instead of the bare error. A turn where nothing
committed yet still yields `error`, unchanged.

Adds middleware/test/orchestrator/committedToolReporting.test.ts and a
CHANGELOG entry.
The emergency-done path added last round for a committed tool call
followed by a later model-call failure skipped sessionLogger.log(),
unlike every other done-emission site in chatStreamInner. Without a
transcript entry, a follow-up turn had no record the tool already ran
and could re-invoke it for any tool without routine-create's
DB-level reconciliation — the duplicate-side-effect class of bug
issue #506 exists to prevent. Log with the same argument shape as
the other sites before yielding; best-effort, never swallows the
done event. Test file now builds the orchestrator with a recording
sessionLogger (the prior 2 tests built none) and asserts the log
call on the emergency path plus its absence on a genuine failure.
…ion (#506)

isSameRoutineRequest compared the stored (store.create()-normalized,
`?? {}`) conversationRef against the raw retry input with no equivalent
default, unlike timeoutMs/outputTemplate. A genuine retry with an omitted
conversationRef (the ordinary non-cold-start create path) therefore fell
through to RoutineNameConflictError instead of reconciling. Apply the
same `?? {}` default the store uses; mirror the fix in both hand-rolled
InMemoryRoutineStore test fakes so the fake's create() matches the real
store's normalization; add a regression test.
…in multi-tool test

The maintainer was presented with generic-across-all-tools vs.
narrowed-to-routine-create-only vs. drop-the-fix-entirely for the
emergency done-instead-of-error path in chatStreamInner and explicitly
chose to keep the current generic, tool-agnostic committedToolNames
tracking, accepting the residual risk that a benign read-only success
can mask a later, silently-skipped mutation.

- Add a doc comment above committedToolNames and above the catch
  block's done-vs-error branch in orchestrator.ts explaining the
  tradeoff, the residual risk, and that this is a deliberate,
  maintainer-reviewed decision (issue #506), not an oversight.
- Add a committedToolReporting.test.ts case that pins this accepted
  behavior: a read-only tool succeeds in one iteration, a later
  iteration's model call throws before it could request a second,
  different (mutating) tool call, and the turn still reports done.
- Update docs/CHANGELOG.md's existing #506 entry to record the
  maintainer's explicit sign-off on this tradeoff.

No production logic changed.
…onale

Prior wording asserted an unverifiable 'the maintainer was presented
with X/Y/Z and explicitly chose' narrative in source comments and the
CHANGELOG. Rephrased as engineering rationale (what was considered,
why the generic behavior was kept) without the review-round framing.
…emplate-conflict-check

# Conflicts:
#	docs/CHANGELOG.md
@Weegy
Weegy merged commit 9a0d946 into main Jul 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant