Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
687b3d6
refactor(space): relocate coding artifact writers/readers out of daem…
lsm Aug 3, 2026
492cac6
test(space): wire coding artifact profile + migrate shape-API tests
lsm Aug 3, 2026
19a723c
fix(space): review-trigger reads current payload; QA records PR as a …
lsm Aug 3, 2026
28deeb3
fix(space): classify legacy merge_conflict_loop notes with warning tone
lsm Aug 3, 2026
9c89d3c
fix(space): migrate end-node REC + list_artifacts schema off legacy type
lsm Aug 3, 2026
28f7cdb
fix(space): outcome decisions carry data.recommendation so save_artif…
lsm Aug 6, 2026
8626073
fix(space): honor explicit note key so per-attempt audit notes don't …
lsm Aug 6, 2026
678469f
fix(space): resolve freshest PR across sources; gate hook reads commi…
lsm Aug 6, 2026
7735b29
test(space): prompt→validator parity regression for migrated save_art…
lsm Aug 6, 2026
28dc5cf
fix(space): derive CodingArtifactProfile db type from repo constructor
lsm Aug 6, 2026
9cfb95b
fix(space): key QA failure notes per cycle so they don't overwrite
lsm Aug 6, 2026
def702b
fix(space): restamp legacy save_artifact type-API prompts to shapes o…
lsm Aug 6, 2026
8f84b27
fix(space): key end-node REC outcome decision so it doesn't clobber t…
lsm Aug 6, 2026
9dad0ac
fix(space): migrate legacy type:"result" prompts via exact restamp pairs
lsm Aug 6, 2026
e23eff3
fix(space): restamp the Coding/Research reviewer preceding sentence +…
lsm Aug 6, 2026
ea269be
fix(test): pass artifactProfile to evaluateTerminalGateFeatures post-…
lsm Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/daemon/src/lib/rpc-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { NodeExecutionRepository } from '../../storage/repositories/node-executi
import { TaskAgentManager } from '../space/runtime/task-agent-manager';
import { ReplyRoutingRegistry } from '../space/runtime/reply-routing-registry';
import { SpaceWorktreeManager } from '../space/managers/space-worktree-manager';
import { CodingArtifactProfile } from '../space/workflows/coding-artifact-profile';
import {
setupSpaceWorkflowHandlers,
checkBuiltInWorkflowDriftOnStartup,
Expand Down Expand Up @@ -615,12 +616,22 @@ export function setupRPCHandlers(deps: RPCHandlerDependencies): RPCHandlerSetupR
// Reply Routing Registry — shared between space-agent-tools (register)
// and task-agent-tools / node-agent-tools (lookup).
const replyRoutingRegistry = new ReplyRoutingRegistry();
// Domain profile that owns coding-specific artifact semantics (which `link`
// is the PR, which `decision` is the terminal outcome, the review-posted-gate
// history). Injected into the runtime services so daemon core never names
// domain kinds (`pr` / `review`).
const artifactProfile = new CodingArtifactProfile({
db: deps.db.getDatabase(),
artifactRepo,
gateDataRepo,
});
const evolutionEpisodeService = new EvolutionEpisodeService({
evolutionRepo: deps.db.evolution,
spaceRepo,
taskRepo: spaceTaskRepo,
workflowRunRepo: spaceWorkflowRunRepo,
artifactRepo,
artifactProfile,
goalService: spaceGoalService,
db: deps.db.getDatabase(),
taskCreatedEventHub: {
Expand Down Expand Up @@ -708,6 +719,7 @@ export function setupRPCHandlers(deps: RPCHandlerDependencies): RPCHandlerSetupR
goalService: spaceGoalService,
evolutionScopeService,
evolutionEpisodeService,
artifactProfile,
});

// When a space is resumed/started, re-seed skipped schedules and re-run restart
Expand Down Expand Up @@ -888,6 +900,7 @@ export function setupRPCHandlers(deps: RPCHandlerDependencies): RPCHandlerSetupR
goalService: spaceGoalService,
evolutionScopeService,
externalEventStore: deps.externalEventStore,
artifactProfile,
});

deps.commandBus.register('agent.message.inject', async (command) => {
Expand Down
14 changes: 11 additions & 3 deletions packages/daemon/src/lib/rpc-handlers/live-query-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,11 +1217,19 @@ artifact_rows AS (
ELSE 'artifact'
END AS category,
CASE
-- Legacy notes that record blockers (mapped from unknown legacy types,
-- original meaning kept under _legacyType) warrant a warning tone.
-- Notes that record blockers/warnings warrant a warning tone. Modern
-- audit notes carry the meaning in kind (merge_blocked / merge_conflict
-- / cleanup_warning); rows backfilled from the legacy freeform type
-- system carry it under _legacyType. Match either so old and new data
-- share the tone.
WHEN wra.artifact_type = 'note'
AND json_valid(wra.data)
AND json_extract(wra.data, '$._legacyType') IN ('merge_blocked', 'cleanup_warning')
AND (
json_extract(wra.data, '$.kind') IN ('merge_blocked', 'merge_conflict', 'cleanup_warning')
OR json_extract(wra.data, '$._legacyType') IN (
'merge_blocked', 'merge_conflict_loop', 'cleanup_warning'
)
)
THEN 'warning'
WHEN wra.artifact_type = 'note' THEN 'progress'
WHEN wra.artifact_type = 'link' THEN 'success'
Expand Down
22 changes: 10 additions & 12 deletions packages/daemon/src/lib/space/evolution-episode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
WorkflowRunArtifactRepository,
} from '../../storage/repositories/workflow-run-artifact-repository';
import type { SpaceGoalService } from './goals/goal-service';
import type { WorkflowArtifactProfile } from './runtime/artifact-profile';
import { isRunningUnderBun, resolveSDKCliPath } from '../agent/sdk-cli-resolver';
import { Logger } from '../logger';
import { getProviderService, mergeProviderEnvVars } from '../provider-service';
Expand Down Expand Up @@ -119,6 +120,12 @@ export interface EvolutionEpisodeServiceDeps {
taskRepo: SpaceTaskRepository;
workflowRunRepo: SpaceWorkflowRunRepository;
artifactRepo: WorkflowRunArtifactRepository;
/**
* Domain artifact profile. Used by the result-artifact gap detector to read
* a run's terminal outcome (coding: the kindless `decision` summary) without
* this service naming domain kinds.
*/
artifactProfile?: WorkflowArtifactProfile;
goalService?: Pick<SpaceGoalService, 'getGoal' | 'updateGoal'>;
taskIdFactory?: () => string;
db?: BunDatabase;
Expand Down Expand Up @@ -482,18 +489,9 @@ export class EvolutionEpisodeService {

let hasResultArtifact = runHasResultArtifact.get(runId);
if (hasResultArtifact === undefined) {
// The terminal "result" is a kind-less `decision` carrying a summary
// (legacy result→decision has no kind; review/gate decisions carry a
// kind and are not terminal).
const decisions = this.deps.artifactRepo.listByRun(runId, {
artifactType: 'decision',
});
hasResultArtifact = decisions.some(
(artifact) =>
!artifact.data.kind &&
typeof artifact.data.summary === 'string' &&
artifact.data.summary.trim().length > 0
);
// Delegated to the domain artifact profile (coding: the kindless
// terminal `decision` summary).
hasResultArtifact = this.deps.artifactProfile?.summarizeRunOutcome(runId) != null;
runHasResultArtifact.set(runId, hasResultArtifact);
}
if (!hasResultArtifact) return;
Expand Down
63 changes: 63 additions & 0 deletions packages/daemon/src/lib/space/runtime/artifact-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Workflow Artifact Profile — the domain seam between generic workflow infra
* (daemon core) and a domain layer.
*
* Infra knows the closed SHAPE vocabulary (`link`, `commit_set`, `check`,
* `metric`, `decision`, `note`) but never a domain KIND (`pr`, `review`, …).
* Anything that depends on what a particular kind means — which `link` is the
* run's primary URL, which `decision` is the terminal outcome, what to persist
* when a particular gate fires — lives behind this interface. The coding-
* workflow layer supplies the implementation; infra only calls the methods.
*
* All methods are best-effort: implementations log and return a safe default
* ('' / null / no-op) on error rather than throwing, so a profile failure can
* never break infra control flow.
*/

/**
* Event passed to {@link WorkflowArtifactProfile.onGateDataCommitted} after a
* gated `send_message` commits a gate-data write (before message delivery).
*/
export interface GateDataCommittedEvent {
runId: string;
nodeId: string;
gateId: string;
/** The committed gate data (after the field merge). */
gateData: Record<string, unknown>;
/**
* The gate-declared fields the sender was authorized to write in this
* `send_message` (the subset of `data` that actually committed to gate_data).
* Domain hooks key side-artifacts off these, not the raw payload, so a field
* the agent sent but was not authorized to write cannot trigger a side-effect.
*/
committedData?: Record<string, unknown>;
/** The raw `data` payload from the originating `send_message` call. */
messageData?: Record<string, unknown>;
}

export interface WorkflowArtifactProfile {
/**
* Resolve the canonical "primary link" URL for a run — the single URL infra
* treats as THE link: gate-script `prUrl`, merge-template `{{pr_url}}`, and PR
* event subscriptions all read this. The coding profile knows it is the PR
* (`link kind:'pr'`); generic infra does not. Returns '' when none.
*/
resolvePrimaryLinkUrl(runId: string): string;

/**
* Build a short terminal outcome summary from a run's artifacts, or null when
* the run recorded no outcome. Used by task completion (mark_complete / run
* completion / Forge gap detection). The coding profile reads the kindless
* terminal `decision`.
*/
summarizeRunOutcome(runId: string): string | null;

/**
* Hook fired after a gated `send_message` commits gate data, before delivery.
* Lets a domain layer persist side-artifacts keyed to that gate. The coding
* profile records a `decision kind:'review'` (round-N) each time the
* review-posted-gate receives a `review_url`. Fire-and-forget from infra's
* perspective: errors are logged by the caller, never propagated.
*/
onGateDataCommitted?(event: GateDataCommittedEvent): Promise<void> | void;
}
4 changes: 2 additions & 2 deletions packages/daemon/src/lib/space/runtime/post-approval-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ const POST_APPROVAL_COMPLETION_INSTRUCTIONS =
`task from \`approved\` to \`done\`. If you are blocked and cannot complete the\n` +
`work, do NOT call mark_complete — the post-approval node-agent surface has no\n` +
`request-human tool, so surface the blocker via send_message(target="space-agent")\n` +
`and save a NON-result artifact describing the block (e.g. type:"blocked"). A\n` +
`"result" artifact would be picked up as the task result on a later mark_complete,\n` +
`and save a NON-result artifact describing the block (e.g. shape:"note", kind:"blocked"). A\n` +
`kindless \`decision\` would be picked up as the task result on a later mark_complete,\n` +
`poisoning completion. Then stop.\n\n` +
`Do NOT call approve_task; the task has already been approved upstream.`;

Expand Down
77 changes: 10 additions & 67 deletions packages/daemon/src/lib/space/runtime/space-runtime-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type { SpaceWorkflowRepository } from '../../../storage/repositories/spac
import type { SpaceAgentInboxRepository } from '../../../storage/repositories/space-agent-inbox-repository';
import { NodeExecutionRepository } from '../../../storage/repositories/node-execution-repository';
import { GateDataRepository } from '../../../storage/repositories/gate-data-repository';
import { WorkflowHookStateRepository } from '../../../storage/repositories/workflow-hook-state-repository';
import type { WorkflowArtifactProfile } from './artifact-profile';
import type { ChannelCycleRepository } from '../../../storage/repositories/channel-cycle-repository';
import type { WorkflowRunArtifactRepository } from '../../../storage/repositories/workflow-run-artifact-repository';
import type { PendingAgentMessageRepository } from '../../../storage/repositories/pending-agent-message-repository';
Expand Down Expand Up @@ -154,6 +154,13 @@ export interface SpaceRuntimeServiceConfig {
* can resolve artifact data for script env injection.
*/
artifactRepo?: WorkflowRunArtifactRepository;
/**
* Domain artifact profile. Passed through to SpaceRuntime and used by this
* service's temporary ChannelRouters to resolve the run's primary link URL
* for feature scripts. Owns coding-specific semantics so neither this service
* nor SpaceRuntime names domain kinds.
*/
artifactProfile?: WorkflowArtifactProfile;
/**
* Optional LLM-backed workflow selector override. Passed through to
* SpaceRuntime verbatim. Defaults to `selectWorkflowWithLlmDefault` which
Expand Down Expand Up @@ -2758,72 +2765,8 @@ export class SpaceRuntimeService {
* can inject PR_URL into feature scripts.
*/
private resolvePrUrlForRun(runId: string): string {
// Only an explicit legacy PR field (pr_url/prUrl) qualifies as a PR URL —
// never a generic data.url (which could be an issue or preview link). The
// sole exception is a `link` artifact tagged kind:'pr' (handled below).
const legacyPrUrl = (data: Record<string, unknown> | undefined): string =>
(typeof data?.prUrl === 'string' && data.prUrl) ||
(typeof data?.pr_url === 'string' && data.pr_url) ||
'';

try {
const gateDataRepo = this.config.gateDataRepo ?? new GateDataRepository(this.config.db);
const gateRecords = gateDataRepo.listByRun(runId).sort((a, b) => b.updatedAt - a.updatedAt);
for (const record of gateRecords) {
const candidate = legacyPrUrl(record.data);
if (candidate) return candidate;
}
} catch (err) {
log.warn(
`SpaceRuntimeService.resolvePrUrlForRun: failed to read gate data for run ${runId}: ${err instanceof Error ? err.message : String(err)}`
);
}

// Scan workflow hook state next. `pr_ready` hooks persist `pr_url` in
// localState after a successful send_message handoff (see
// workflow-hook-engine.ts); without this scan the resolver cannot find
// PR URLs for review-approval-gate when that gate's schema does not
// declare `pr_url` (the typical Review→QA handoff case).
try {
const hookStateRepo = new WorkflowHookStateRepository(this.config.db);
const hookStates = hookStateRepo
.listByRun(runId)
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
for (const snapshot of hookStates) {
const candidate = legacyPrUrl(snapshot.localState);
if (candidate) return candidate;
}
} catch (err) {
log.warn(
`SpaceRuntimeService.resolvePrUrlForRun: failed to read hook state for run ${runId}: ${err instanceof Error ? err.message : String(err)}`
);
}

if (this.config.artifactRepo) {
try {
// Return the most recently updated eligible PR candidate — a link
// kind:'pr' (data.url) or a legacy pr_url/prUrl row.
const artifacts = this.config.artifactRepo.listByRun(runId);
let best: { url: string; updatedAt: number } | null = null;
for (const a of artifacts) {
const url =
a.artifactType === 'link' && a.data.kind === 'pr'
? typeof a.data.url === 'string'
? a.data.url
: ''
: legacyPrUrl(a.data);
if (!url) continue;
if (!best || a.updatedAt > best.updatedAt) best = { url, updatedAt: a.updatedAt };
}
if (best) return best.url;
} catch (err) {
log.warn(
`SpaceRuntimeService.resolvePrUrlForRun: failed to read artifacts for run ${runId}: ${err instanceof Error ? err.message : String(err)}`
);
}
}

return '';
// Delegated to the domain artifact profile (coding: resolves the PR URL).
return this.config.artifactProfile?.resolvePrimaryLinkUrl(runId) ?? '';
}

/**
Expand Down
Loading