);
}
+ if (sessionId === null) {
+ // An `attached` session with no link is the public floor's redacted shape:
+ // there is no session id to fetch, so render graceful copy instead of
+ // dereferencing an absent link (which previously crashed the run view).
+ return (
+
+ Session transcript is unavailable for this node.
+
+ );
+ }
const badge = streamBadge(sessionState.stream);
const loading = sessionState.status === 'loading';
const result = sessionState.status === 'ready' ? sessionState.result : null;
diff --git a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/index.ts b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/index.ts
index 1df8002a31..44421303fd 100644
--- a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/index.ts
+++ b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/index.ts
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export { addPack, createAgent, createBead, createConvoy, createProvider, createRig, createSession, deleteV0CityByCityNameAgentByBase, deleteV0CityByCityNameAgentByDirByBase, deleteV0CityByCityNameBeadById, deleteV0CityByCityNameConvoyById, deleteV0CityByCityNameExtmsgAdapters, deleteV0CityByCityNameExtmsgParticipants, deleteV0CityByCityNameFormulasByName, deleteV0CityByCityNameMailById, deleteV0CityByCityNamePacksByName, deleteV0CityByCityNamePatchesAgentByBase, deleteV0CityByCityNamePatchesAgentByDirByBase, deleteV0CityByCityNamePatchesProviderByName, deleteV0CityByCityNamePatchesRigByName, deleteV0CityByCityNameProviderByName, deleteV0CityByCityNameRigByName, deleteV0CityByCityNameWorkflowByWorkflowId, emitEvent, ensureExtmsgGroup, getHealth, getV0Cities, getV0CityByCityName, getV0CityByCityNameAgentByBase, getV0CityByCityNameAgentByBaseOutput, getV0CityByCityNameAgentByDirByBase, getV0CityByCityNameAgentByDirByBaseOutput, getV0CityByCityNameAgents, getV0CityByCityNameBeadById, getV0CityByCityNameBeadByIdDeps, getV0CityByCityNameBeads, getV0CityByCityNameBeadsGraphByRootId, getV0CityByCityNameBeadsReady, getV0CityByCityNameConfig, getV0CityByCityNameConfigDefaults, getV0CityByCityNameConfigExplain, getV0CityByCityNameConfigValidate, getV0CityByCityNameConvoyById, getV0CityByCityNameConvoyByIdCheck, getV0CityByCityNameConvoys, getV0CityByCityNameEvents, getV0CityByCityNameExtmsgAdapters, getV0CityByCityNameExtmsgBindings, getV0CityByCityNameExtmsgGroups, getV0CityByCityNameExtmsgTranscript, getV0CityByCityNameFormulaByName, getV0CityByCityNameFormulas, getV0CityByCityNameFormulasByName, getV0CityByCityNameFormulasByNameRuns, getV0CityByCityNameFormulasByNameSource, getV0CityByCityNameFormulasFeed, getV0CityByCityNameHealth, getV0CityByCityNameMail, getV0CityByCityNameMailById, getV0CityByCityNameMailCount, getV0CityByCityNameMailThreadById, getV0CityByCityNameMaintenanceStatus, getV0CityByCityNameOrderByName, getV0CityByCityNameOrderHistoryByBeadId, getV0CityByCityNameOrders, getV0CityByCityNameOrdersCheck, getV0CityByCityNameOrdersFeed, getV0CityByCityNameOrdersHistory, getV0CityByCityNamePacks, getV0CityByCityNamePatchesAgentByBase, getV0CityByCityNamePatchesAgentByDirByBase, getV0CityByCityNamePatchesAgents, getV0CityByCityNamePatchesProviderByName, getV0CityByCityNamePatchesProviders, getV0CityByCityNamePatchesRigByName, getV0CityByCityNamePatchesRigs, getV0CityByCityNamePending, getV0CityByCityNameProviderByName, getV0CityByCityNameProviderReadiness, getV0CityByCityNameProviders, getV0CityByCityNameProvidersPublic, getV0CityByCityNameReadiness, getV0CityByCityNameRigByName, getV0CityByCityNameRigs, getV0CityByCityNameRuns, getV0CityByCityNameRunsByRunId, getV0CityByCityNameRunsByRunIdSteps, getV0CityByCityNameRunsCensus, getV0CityByCityNameServiceByName, getV0CityByCityNameServices, getV0CityByCityNameSessionById, getV0CityByCityNameSessionByIdAgents, getV0CityByCityNameSessionByIdAgentsByAgentId, getV0CityByCityNameSessionByIdPending, getV0CityByCityNameSessionByIdTranscript, getV0CityByCityNameSessions, getV0CityByCityNameStatus, getV0CityByCityNameUsage, getV0CityByCityNameWaitById, getV0CityByCityNameWaits, getV0CityByCityNameWorkflowByWorkflowId, getV0Events, getV0ProviderReadiness, getV0Readiness, type Options, patchV0CityByCityName, patchV0CityByCityNameAgentByBase, patchV0CityByCityNameAgentByDirByBase, patchV0CityByCityNameBeadById, patchV0CityByCityNameProviderByName, patchV0CityByCityNameRigByName, patchV0CityByCityNameSessionById, postV0City, postV0CityByCityNameAgentByBaseByAction, postV0CityByCityNameAgentByDirByBaseByAction, postV0CityByCityNameBeadByIdAssign, postV0CityByCityNameBeadByIdClose, postV0CityByCityNameBeadByIdReopen, postV0CityByCityNameBeadByIdUpdate, postV0CityByCityNameConvoyByIdAdd, postV0CityByCityNameConvoyByIdClose, postV0CityByCityNameConvoyByIdRemove, postV0CityByCityNameExtmsgBind, postV0CityByCityNameExtmsgInbound, postV0CityByCityNameExtmsgOutbound, postV0CityByCityNameExtmsgParticipants, postV0CityByCityNameExtmsgTranscriptAck, postV0CityByCityNameExtmsgUnbind, postV0CityByCityNameFormulasByNamePreview, postV0CityByCityNameFormulasByNameValidate, postV0CityByCityNameMailByIdArchive, postV0CityByCityNameMailByIdMarkUnread, postV0CityByCityNameMailByIdRead, postV0CityByCityNameOrderByNameDisable, postV0CityByCityNameOrderByNameEnable, postV0CityByCityNameOrderByNameRun, postV0CityByCityNameRigByNameByAction, postV0CityByCityNameRunsByRunIdCancel, postV0CityByCityNameServiceByNameRestart, postV0CityByCityNameSessionByIdClose, postV0CityByCityNameSessionByIdKill, postV0CityByCityNameSessionByIdPermissionMode, postV0CityByCityNameSessionByIdRename, postV0CityByCityNameSessionByIdStop, postV0CityByCityNameSessionByIdSuspend, postV0CityByCityNameSessionByIdWake, postV0CityByCityNameSling, postV0CityByCityNameUnregister, putV0CityByCityNameFormulasByName, putV0CityByCityNamePatchesAgents, putV0CityByCityNamePatchesProviders, putV0CityByCityNamePatchesRigs, registerExtmsgAdapter, replyMail, respondSession, rotateEvents, sendMail, sendSessionMessage, streamAgentOutput, streamAgentOutputQualified, streamEvents, streamSession, streamSupervisorEvents, submitSession, triggerMaintenanceDoltGc } from './sdk.gen.js';
-export type { AdapterCapabilities, AdapterEventPayload, AddPackData, AddPackError, AddPackErrors, AddPackResponse, AddPackResponses, AgentCreatedOutputBody, AgentCreateInputBody, AgentMapping, AgentOutputResponse, AgentPatch, AgentPatchSetInputBody, AgentResponse, AgentUpdateInputBody, AgentUpdateQualifiedInputBody, AnnotatedAgentResponse, AnnotatedProviderResponse, AsyncAcceptedBody, AsyncAcceptedResponse, Bead, BeadAssignInputBody, BeadClaimRejectedPayload, BeadCreateInputBody, BeadDeadAssigneeReopenedPayload, BeadDepsResponse, BeadEventPayload, BeadGraphResponse, BeadsDiagnostic, BeadUpdateBody, BeadWorktreeReapedPayload, BeadWorktreeReapSkippedPayload, BindingStatus, BoundEventPayload, BreakerStateChangedPayload, CityCreateRequest, CityCreateSucceededPayload, CityGetResponse, CityInfo, CityLifecyclePayload, CityPatchInputBody, CityPendingEntry, CityUnregisterSucceededPayload, ClientOptions, ConditionalWritesDegradedPayload, ConfigAgentResponse, ConfigExplainPatches, ConfigExplainResponse, ConfigPatchesResponse, ConfigResponse, ConfigRigResponse, ConfigValidateOutputBody, ControllerTickCompletedPayload, ConversationGroupParticipant, ConversationGroupRecord, ConversationKind, ConversationRef, ConversationTranscriptRecord, ConvoyAddInputBody, ConvoyCheckResponse, ConvoyCreateInputBody, ConvoyGetResponse, ConvoyProgress, ConvoyRemoveInputBody, CreateAgentData, CreateAgentError, CreateAgentErrors, CreateAgentResponse, CreateAgentResponses, CreateBeadData, CreateBeadError, CreateBeadErrors, CreateBeadResponse, CreateBeadResponses, CreateConvoyData, CreateConvoyError, CreateConvoyErrors, CreateConvoyResponse, CreateConvoyResponses, CreateProviderData, CreateProviderError, CreateProviderErrors, CreateProviderResponse, CreateProviderResponses, CreateRigData, CreateRigError, CreateRigErrors, CreateRigResponse, CreateRigResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteV0CityByCityNameAgentByBaseData, DeleteV0CityByCityNameAgentByBaseError, DeleteV0CityByCityNameAgentByBaseErrors, DeleteV0CityByCityNameAgentByBaseResponse, DeleteV0CityByCityNameAgentByBaseResponses, DeleteV0CityByCityNameAgentByDirByBaseData, DeleteV0CityByCityNameAgentByDirByBaseError, DeleteV0CityByCityNameAgentByDirByBaseErrors, DeleteV0CityByCityNameAgentByDirByBaseResponse, DeleteV0CityByCityNameAgentByDirByBaseResponses, DeleteV0CityByCityNameBeadByIdData, DeleteV0CityByCityNameBeadByIdError, DeleteV0CityByCityNameBeadByIdErrors, DeleteV0CityByCityNameBeadByIdResponse, DeleteV0CityByCityNameBeadByIdResponses, DeleteV0CityByCityNameConvoyByIdData, DeleteV0CityByCityNameConvoyByIdError, DeleteV0CityByCityNameConvoyByIdErrors, DeleteV0CityByCityNameConvoyByIdResponse, DeleteV0CityByCityNameConvoyByIdResponses, DeleteV0CityByCityNameExtmsgAdaptersData, DeleteV0CityByCityNameExtmsgAdaptersError, DeleteV0CityByCityNameExtmsgAdaptersErrors, DeleteV0CityByCityNameExtmsgAdaptersResponse, DeleteV0CityByCityNameExtmsgAdaptersResponses, DeleteV0CityByCityNameExtmsgParticipantsData, DeleteV0CityByCityNameExtmsgParticipantsError, DeleteV0CityByCityNameExtmsgParticipantsErrors, DeleteV0CityByCityNameExtmsgParticipantsResponse, DeleteV0CityByCityNameExtmsgParticipantsResponses, DeleteV0CityByCityNameFormulasByNameData, DeleteV0CityByCityNameFormulasByNameError, DeleteV0CityByCityNameFormulasByNameErrors, DeleteV0CityByCityNameFormulasByNameResponse, DeleteV0CityByCityNameFormulasByNameResponses, DeleteV0CityByCityNameMailByIdData, DeleteV0CityByCityNameMailByIdError, DeleteV0CityByCityNameMailByIdErrors, DeleteV0CityByCityNameMailByIdResponse, DeleteV0CityByCityNameMailByIdResponses, DeleteV0CityByCityNamePacksByNameData, DeleteV0CityByCityNamePacksByNameError, DeleteV0CityByCityNamePacksByNameErrors, DeleteV0CityByCityNamePacksByNameResponse, DeleteV0CityByCityNamePacksByNameResponses, DeleteV0CityByCityNamePatchesAgentByBaseData, DeleteV0CityByCityNamePatchesAgentByBaseError, DeleteV0CityByCityNamePatchesAgentByBaseErrors, DeleteV0CityByCityNamePatchesAgentByBaseResponse, DeleteV0CityByCityNamePatchesAgentByBaseResponses, DeleteV0CityByCityNamePatchesAgentByDirByBaseData, DeleteV0CityByCityNamePatchesAgentByDirByBaseError, DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses, DeleteV0CityByCityNamePatchesProviderByNameData, DeleteV0CityByCityNamePatchesProviderByNameError, DeleteV0CityByCityNamePatchesProviderByNameErrors, DeleteV0CityByCityNamePatchesProviderByNameResponse, DeleteV0CityByCityNamePatchesProviderByNameResponses, DeleteV0CityByCityNamePatchesRigByNameData, DeleteV0CityByCityNamePatchesRigByNameError, DeleteV0CityByCityNamePatchesRigByNameErrors, DeleteV0CityByCityNamePatchesRigByNameResponse, DeleteV0CityByCityNamePatchesRigByNameResponses, DeleteV0CityByCityNameProviderByNameData, DeleteV0CityByCityNameProviderByNameError, DeleteV0CityByCityNameProviderByNameErrors, DeleteV0CityByCityNameProviderByNameResponse, DeleteV0CityByCityNameProviderByNameResponses, DeleteV0CityByCityNameRigByNameData, DeleteV0CityByCityNameRigByNameError, DeleteV0CityByCityNameRigByNameErrors, DeleteV0CityByCityNameRigByNameResponse, DeleteV0CityByCityNameRigByNameResponses, DeleteV0CityByCityNameWorkflowByWorkflowIdData, DeleteV0CityByCityNameWorkflowByWorkflowIdError, DeleteV0CityByCityNameWorkflowByWorkflowIdErrors, DeleteV0CityByCityNameWorkflowByWorkflowIdResponse, DeleteV0CityByCityNameWorkflowByWorkflowIdResponses, DeliveryContextRecord, Dep, DoctorAlertPayload, EmitEventData, EmitEventError, EmitEventErrors, EmitEventResponse, EmitEventResponses, EnsureExtmsgGroupData, EnsureExtmsgGroupError, EnsureExtmsgGroupErrors, EnsureExtmsgGroupResponse, EnsureExtmsgGroupResponses, ErrorDetail, ErrorModel, EventEmitOutputBody, EventEmitRequest, EventPayload, EventRotateAnchor, EventRotateArchive, EventRotateResponse, EventStreamEnvelope, ExternalActor, ExternalAttachment, ExternalInboundMessage, ExtmsgAdapterInfo, ExtMsgAdapterRegisterInputBody, ExtMsgAdapterRegisterOutputBody, ExtMsgAdapterUnregisterInputBody, ExtMsgBindInputBody, ExtMsgGroupEnsureInputBody, ExtMsgInboundInputBody, ExtMsgOutboundInputBody, ExtMsgParticipantRemoveInputBody, ExtMsgParticipantUpsertInputBody, ExtMsgTranscriptAckInputBody, ExtMsgUnbindBody, ExtMsgUnbindInputBody, FanoutPolicy, FormulaDetailResponse, FormulaFeedBody, FormulaListBody, FormulaPreviewBody, FormulaPreviewEdgeResponse, FormulaPreviewNodeResponse, FormulaPreviewResponse, FormulaRecentRunResponse, FormulaRunsResponse, FormulaSourceOutputBody, FormulaStepResponse, FormulaSummaryResponse, FormulaValidateOutputBody, FormulaVarDefResponse, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetV0CitiesData, GetV0CitiesError, GetV0CitiesErrors, GetV0CitiesResponse, GetV0CitiesResponses, GetV0CityByCityNameAgentByBaseData, GetV0CityByCityNameAgentByBaseError, GetV0CityByCityNameAgentByBaseErrors, GetV0CityByCityNameAgentByBaseOutputData, GetV0CityByCityNameAgentByBaseOutputError, GetV0CityByCityNameAgentByBaseOutputErrors, GetV0CityByCityNameAgentByBaseOutputResponse, GetV0CityByCityNameAgentByBaseOutputResponses, GetV0CityByCityNameAgentByBaseResponse, GetV0CityByCityNameAgentByBaseResponses, GetV0CityByCityNameAgentByDirByBaseData, GetV0CityByCityNameAgentByDirByBaseError, GetV0CityByCityNameAgentByDirByBaseErrors, GetV0CityByCityNameAgentByDirByBaseOutputData, GetV0CityByCityNameAgentByDirByBaseOutputError, GetV0CityByCityNameAgentByDirByBaseOutputErrors, GetV0CityByCityNameAgentByDirByBaseOutputResponse, GetV0CityByCityNameAgentByDirByBaseOutputResponses, GetV0CityByCityNameAgentByDirByBaseResponse, GetV0CityByCityNameAgentByDirByBaseResponses, GetV0CityByCityNameAgentsData, GetV0CityByCityNameAgentsError, GetV0CityByCityNameAgentsErrors, GetV0CityByCityNameAgentsResponse, GetV0CityByCityNameAgentsResponses, GetV0CityByCityNameBeadByIdData, GetV0CityByCityNameBeadByIdDepsData, GetV0CityByCityNameBeadByIdDepsError, GetV0CityByCityNameBeadByIdDepsErrors, GetV0CityByCityNameBeadByIdDepsResponse, GetV0CityByCityNameBeadByIdDepsResponses, GetV0CityByCityNameBeadByIdError, GetV0CityByCityNameBeadByIdErrors, GetV0CityByCityNameBeadByIdResponse, GetV0CityByCityNameBeadByIdResponses, GetV0CityByCityNameBeadsData, GetV0CityByCityNameBeadsError, GetV0CityByCityNameBeadsErrors, GetV0CityByCityNameBeadsGraphByRootIdData, GetV0CityByCityNameBeadsGraphByRootIdError, GetV0CityByCityNameBeadsGraphByRootIdErrors, GetV0CityByCityNameBeadsGraphByRootIdResponse, GetV0CityByCityNameBeadsGraphByRootIdResponses, GetV0CityByCityNameBeadsReadyData, GetV0CityByCityNameBeadsReadyError, GetV0CityByCityNameBeadsReadyErrors, GetV0CityByCityNameBeadsReadyResponse, GetV0CityByCityNameBeadsReadyResponses, GetV0CityByCityNameBeadsResponse, GetV0CityByCityNameBeadsResponses, GetV0CityByCityNameConfigData, GetV0CityByCityNameConfigDefaultsData, GetV0CityByCityNameConfigDefaultsError, GetV0CityByCityNameConfigDefaultsErrors, GetV0CityByCityNameConfigDefaultsResponse, GetV0CityByCityNameConfigDefaultsResponses, GetV0CityByCityNameConfigError, GetV0CityByCityNameConfigErrors, GetV0CityByCityNameConfigExplainData, GetV0CityByCityNameConfigExplainError, GetV0CityByCityNameConfigExplainErrors, GetV0CityByCityNameConfigExplainResponse, GetV0CityByCityNameConfigExplainResponses, GetV0CityByCityNameConfigResponse, GetV0CityByCityNameConfigResponses, GetV0CityByCityNameConfigValidateData, GetV0CityByCityNameConfigValidateError, GetV0CityByCityNameConfigValidateErrors, GetV0CityByCityNameConfigValidateResponse, GetV0CityByCityNameConfigValidateResponses, GetV0CityByCityNameConvoyByIdCheckData, GetV0CityByCityNameConvoyByIdCheckError, GetV0CityByCityNameConvoyByIdCheckErrors, GetV0CityByCityNameConvoyByIdCheckResponse, GetV0CityByCityNameConvoyByIdCheckResponses, GetV0CityByCityNameConvoyByIdData, GetV0CityByCityNameConvoyByIdError, GetV0CityByCityNameConvoyByIdErrors, GetV0CityByCityNameConvoyByIdResponse, GetV0CityByCityNameConvoyByIdResponses, GetV0CityByCityNameConvoysData, GetV0CityByCityNameConvoysError, GetV0CityByCityNameConvoysErrors, GetV0CityByCityNameConvoysResponse, GetV0CityByCityNameConvoysResponses, GetV0CityByCityNameData, GetV0CityByCityNameError, GetV0CityByCityNameErrors, GetV0CityByCityNameEventsData, GetV0CityByCityNameEventsError, GetV0CityByCityNameEventsErrors, GetV0CityByCityNameEventsResponse, GetV0CityByCityNameEventsResponses, GetV0CityByCityNameExtmsgAdaptersData, GetV0CityByCityNameExtmsgAdaptersError, GetV0CityByCityNameExtmsgAdaptersErrors, GetV0CityByCityNameExtmsgAdaptersResponse, GetV0CityByCityNameExtmsgAdaptersResponses, GetV0CityByCityNameExtmsgBindingsData, GetV0CityByCityNameExtmsgBindingsError, GetV0CityByCityNameExtmsgBindingsErrors, GetV0CityByCityNameExtmsgBindingsResponse, GetV0CityByCityNameExtmsgBindingsResponses, GetV0CityByCityNameExtmsgGroupsData, GetV0CityByCityNameExtmsgGroupsError, GetV0CityByCityNameExtmsgGroupsErrors, GetV0CityByCityNameExtmsgGroupsResponse, GetV0CityByCityNameExtmsgGroupsResponses, GetV0CityByCityNameExtmsgTranscriptData, GetV0CityByCityNameExtmsgTranscriptError, GetV0CityByCityNameExtmsgTranscriptErrors, GetV0CityByCityNameExtmsgTranscriptResponse, GetV0CityByCityNameExtmsgTranscriptResponses, GetV0CityByCityNameFormulaByNameData, GetV0CityByCityNameFormulaByNameError, GetV0CityByCityNameFormulaByNameErrors, GetV0CityByCityNameFormulaByNameResponse, GetV0CityByCityNameFormulaByNameResponses, GetV0CityByCityNameFormulasByNameData, GetV0CityByCityNameFormulasByNameError, GetV0CityByCityNameFormulasByNameErrors, GetV0CityByCityNameFormulasByNameResponse, GetV0CityByCityNameFormulasByNameResponses, GetV0CityByCityNameFormulasByNameRunsData, GetV0CityByCityNameFormulasByNameRunsError, GetV0CityByCityNameFormulasByNameRunsErrors, GetV0CityByCityNameFormulasByNameRunsResponse, GetV0CityByCityNameFormulasByNameRunsResponses, GetV0CityByCityNameFormulasByNameSourceData, GetV0CityByCityNameFormulasByNameSourceError, GetV0CityByCityNameFormulasByNameSourceErrors, GetV0CityByCityNameFormulasByNameSourceResponse, GetV0CityByCityNameFormulasByNameSourceResponses, GetV0CityByCityNameFormulasData, GetV0CityByCityNameFormulasError, GetV0CityByCityNameFormulasErrors, GetV0CityByCityNameFormulasFeedData, GetV0CityByCityNameFormulasFeedError, GetV0CityByCityNameFormulasFeedErrors, GetV0CityByCityNameFormulasFeedResponse, GetV0CityByCityNameFormulasFeedResponses, GetV0CityByCityNameFormulasResponse, GetV0CityByCityNameFormulasResponses, GetV0CityByCityNameHealthData, GetV0CityByCityNameHealthError, GetV0CityByCityNameHealthErrors, GetV0CityByCityNameHealthResponse, GetV0CityByCityNameHealthResponses, GetV0CityByCityNameMailByIdData, GetV0CityByCityNameMailByIdError, GetV0CityByCityNameMailByIdErrors, GetV0CityByCityNameMailByIdResponse, GetV0CityByCityNameMailByIdResponses, GetV0CityByCityNameMailCountData, GetV0CityByCityNameMailCountError, GetV0CityByCityNameMailCountErrors, GetV0CityByCityNameMailCountResponse, GetV0CityByCityNameMailCountResponses, GetV0CityByCityNameMailData, GetV0CityByCityNameMailError, GetV0CityByCityNameMailErrors, GetV0CityByCityNameMailResponse, GetV0CityByCityNameMailResponses, GetV0CityByCityNameMailThreadByIdData, GetV0CityByCityNameMailThreadByIdError, GetV0CityByCityNameMailThreadByIdErrors, GetV0CityByCityNameMailThreadByIdResponse, GetV0CityByCityNameMailThreadByIdResponses, GetV0CityByCityNameMaintenanceStatusData, GetV0CityByCityNameMaintenanceStatusError, GetV0CityByCityNameMaintenanceStatusErrors, GetV0CityByCityNameMaintenanceStatusResponse, GetV0CityByCityNameMaintenanceStatusResponses, GetV0CityByCityNameOrderByNameData, GetV0CityByCityNameOrderByNameError, GetV0CityByCityNameOrderByNameErrors, GetV0CityByCityNameOrderByNameResponse, GetV0CityByCityNameOrderByNameResponses, GetV0CityByCityNameOrderHistoryByBeadIdData, GetV0CityByCityNameOrderHistoryByBeadIdError, GetV0CityByCityNameOrderHistoryByBeadIdErrors, GetV0CityByCityNameOrderHistoryByBeadIdResponse, GetV0CityByCityNameOrderHistoryByBeadIdResponses, GetV0CityByCityNameOrdersCheckData, GetV0CityByCityNameOrdersCheckError, GetV0CityByCityNameOrdersCheckErrors, GetV0CityByCityNameOrdersCheckResponse, GetV0CityByCityNameOrdersCheckResponses, GetV0CityByCityNameOrdersData, GetV0CityByCityNameOrdersError, GetV0CityByCityNameOrdersErrors, GetV0CityByCityNameOrdersFeedData, GetV0CityByCityNameOrdersFeedError, GetV0CityByCityNameOrdersFeedErrors, GetV0CityByCityNameOrdersFeedResponse, GetV0CityByCityNameOrdersFeedResponses, GetV0CityByCityNameOrdersHistoryData, GetV0CityByCityNameOrdersHistoryError, GetV0CityByCityNameOrdersHistoryErrors, GetV0CityByCityNameOrdersHistoryResponse, GetV0CityByCityNameOrdersHistoryResponses, GetV0CityByCityNameOrdersResponse, GetV0CityByCityNameOrdersResponses, GetV0CityByCityNamePacksData, GetV0CityByCityNamePacksError, GetV0CityByCityNamePacksErrors, GetV0CityByCityNamePacksResponse, GetV0CityByCityNamePacksResponses, GetV0CityByCityNamePatchesAgentByBaseData, GetV0CityByCityNamePatchesAgentByBaseError, GetV0CityByCityNamePatchesAgentByBaseErrors, GetV0CityByCityNamePatchesAgentByBaseResponse, GetV0CityByCityNamePatchesAgentByBaseResponses, GetV0CityByCityNamePatchesAgentByDirByBaseData, GetV0CityByCityNamePatchesAgentByDirByBaseError, GetV0CityByCityNamePatchesAgentByDirByBaseErrors, GetV0CityByCityNamePatchesAgentByDirByBaseResponse, GetV0CityByCityNamePatchesAgentByDirByBaseResponses, GetV0CityByCityNamePatchesAgentsData, GetV0CityByCityNamePatchesAgentsError, GetV0CityByCityNamePatchesAgentsErrors, GetV0CityByCityNamePatchesAgentsResponse, GetV0CityByCityNamePatchesAgentsResponses, GetV0CityByCityNamePatchesProviderByNameData, GetV0CityByCityNamePatchesProviderByNameError, GetV0CityByCityNamePatchesProviderByNameErrors, GetV0CityByCityNamePatchesProviderByNameResponse, GetV0CityByCityNamePatchesProviderByNameResponses, GetV0CityByCityNamePatchesProvidersData, GetV0CityByCityNamePatchesProvidersError, GetV0CityByCityNamePatchesProvidersErrors, GetV0CityByCityNamePatchesProvidersResponse, GetV0CityByCityNamePatchesProvidersResponses, GetV0CityByCityNamePatchesRigByNameData, GetV0CityByCityNamePatchesRigByNameError, GetV0CityByCityNamePatchesRigByNameErrors, GetV0CityByCityNamePatchesRigByNameResponse, GetV0CityByCityNamePatchesRigByNameResponses, GetV0CityByCityNamePatchesRigsData, GetV0CityByCityNamePatchesRigsError, GetV0CityByCityNamePatchesRigsErrors, GetV0CityByCityNamePatchesRigsResponse, GetV0CityByCityNamePatchesRigsResponses, GetV0CityByCityNamePendingData, GetV0CityByCityNamePendingError, GetV0CityByCityNamePendingErrors, GetV0CityByCityNamePendingResponse, GetV0CityByCityNamePendingResponses, GetV0CityByCityNameProviderByNameData, GetV0CityByCityNameProviderByNameError, GetV0CityByCityNameProviderByNameErrors, GetV0CityByCityNameProviderByNameResponse, GetV0CityByCityNameProviderByNameResponses, GetV0CityByCityNameProviderReadinessData, GetV0CityByCityNameProviderReadinessError, GetV0CityByCityNameProviderReadinessErrors, GetV0CityByCityNameProviderReadinessResponse, GetV0CityByCityNameProviderReadinessResponses, GetV0CityByCityNameProvidersData, GetV0CityByCityNameProvidersError, GetV0CityByCityNameProvidersErrors, GetV0CityByCityNameProvidersPublicData, GetV0CityByCityNameProvidersPublicError, GetV0CityByCityNameProvidersPublicErrors, GetV0CityByCityNameProvidersPublicResponse, GetV0CityByCityNameProvidersPublicResponses, GetV0CityByCityNameProvidersResponse, GetV0CityByCityNameProvidersResponses, GetV0CityByCityNameReadinessData, GetV0CityByCityNameReadinessError, GetV0CityByCityNameReadinessErrors, GetV0CityByCityNameReadinessResponse, GetV0CityByCityNameReadinessResponses, GetV0CityByCityNameResponse, GetV0CityByCityNameResponses, GetV0CityByCityNameRigByNameData, GetV0CityByCityNameRigByNameError, GetV0CityByCityNameRigByNameErrors, GetV0CityByCityNameRigByNameResponse, GetV0CityByCityNameRigByNameResponses, GetV0CityByCityNameRigsData, GetV0CityByCityNameRigsError, GetV0CityByCityNameRigsErrors, GetV0CityByCityNameRigsResponse, GetV0CityByCityNameRigsResponses, GetV0CityByCityNameRunsByRunIdData, GetV0CityByCityNameRunsByRunIdError, GetV0CityByCityNameRunsByRunIdErrors, GetV0CityByCityNameRunsByRunIdResponse, GetV0CityByCityNameRunsByRunIdResponses, GetV0CityByCityNameRunsByRunIdStepsData, GetV0CityByCityNameRunsByRunIdStepsError, GetV0CityByCityNameRunsByRunIdStepsErrors, GetV0CityByCityNameRunsByRunIdStepsResponse, GetV0CityByCityNameRunsByRunIdStepsResponses, GetV0CityByCityNameRunsCensusData, GetV0CityByCityNameRunsCensusError, GetV0CityByCityNameRunsCensusErrors, GetV0CityByCityNameRunsCensusResponse, GetV0CityByCityNameRunsCensusResponses, GetV0CityByCityNameRunsData, GetV0CityByCityNameRunsError, GetV0CityByCityNameRunsErrors, GetV0CityByCityNameRunsResponse, GetV0CityByCityNameRunsResponses, GetV0CityByCityNameServiceByNameData, GetV0CityByCityNameServiceByNameError, GetV0CityByCityNameServiceByNameErrors, GetV0CityByCityNameServiceByNameResponse, GetV0CityByCityNameServiceByNameResponses, GetV0CityByCityNameServicesData, GetV0CityByCityNameServicesError, GetV0CityByCityNameServicesErrors, GetV0CityByCityNameServicesResponse, GetV0CityByCityNameServicesResponses, GetV0CityByCityNameSessionByIdAgentsByAgentIdData, GetV0CityByCityNameSessionByIdAgentsByAgentIdError, GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses, GetV0CityByCityNameSessionByIdAgentsData, GetV0CityByCityNameSessionByIdAgentsError, GetV0CityByCityNameSessionByIdAgentsErrors, GetV0CityByCityNameSessionByIdAgentsResponse, GetV0CityByCityNameSessionByIdAgentsResponses, GetV0CityByCityNameSessionByIdData, GetV0CityByCityNameSessionByIdError, GetV0CityByCityNameSessionByIdErrors, GetV0CityByCityNameSessionByIdPendingData, GetV0CityByCityNameSessionByIdPendingError, GetV0CityByCityNameSessionByIdPendingErrors, GetV0CityByCityNameSessionByIdPendingResponse, GetV0CityByCityNameSessionByIdPendingResponses, GetV0CityByCityNameSessionByIdResponse, GetV0CityByCityNameSessionByIdResponses, GetV0CityByCityNameSessionByIdTranscriptData, GetV0CityByCityNameSessionByIdTranscriptError, GetV0CityByCityNameSessionByIdTranscriptErrors, GetV0CityByCityNameSessionByIdTranscriptResponse, GetV0CityByCityNameSessionByIdTranscriptResponses, GetV0CityByCityNameSessionsData, GetV0CityByCityNameSessionsError, GetV0CityByCityNameSessionsErrors, GetV0CityByCityNameSessionsResponse, GetV0CityByCityNameSessionsResponses, GetV0CityByCityNameStatusData, GetV0CityByCityNameStatusError, GetV0CityByCityNameStatusErrors, GetV0CityByCityNameStatusResponse, GetV0CityByCityNameStatusResponses, GetV0CityByCityNameUsageData, GetV0CityByCityNameUsageError, GetV0CityByCityNameUsageErrors, GetV0CityByCityNameUsageResponse, GetV0CityByCityNameUsageResponses, GetV0CityByCityNameWaitByIdData, GetV0CityByCityNameWaitByIdError, GetV0CityByCityNameWaitByIdErrors, GetV0CityByCityNameWaitByIdResponse, GetV0CityByCityNameWaitByIdResponses, GetV0CityByCityNameWaitsData, GetV0CityByCityNameWaitsError, GetV0CityByCityNameWaitsErrors, GetV0CityByCityNameWaitsResponse, GetV0CityByCityNameWaitsResponses, GetV0CityByCityNameWorkflowByWorkflowIdData, GetV0CityByCityNameWorkflowByWorkflowIdError, GetV0CityByCityNameWorkflowByWorkflowIdErrors, GetV0CityByCityNameWorkflowByWorkflowIdResponse, GetV0CityByCityNameWorkflowByWorkflowIdResponses, GetV0EventsData, GetV0EventsError, GetV0EventsErrors, GetV0EventsResponse, GetV0EventsResponses, GetV0ProviderReadinessData, GetV0ProviderReadinessError, GetV0ProviderReadinessErrors, GetV0ProviderReadinessResponse, GetV0ProviderReadinessResponses, GetV0ReadinessData, GetV0ReadinessError, GetV0ReadinessErrors, GetV0ReadinessResponse, GetV0ReadinessResponses, GitStatus, GroupCreatedEventPayload, GroupRouteDecision, HealthOutputBody, HeartbeatEvent, InboundEventPayload, InboundResult, ListBodyAgentPatch, ListBodyAgentResponse, ListBodyBead, ListBodyCityPendingEntry, ListBodyConversationTranscriptRecord, ListBodyExtmsgAdapterInfo, ListBodyProviderPatch, ListBodyProviderResponse, ListBodyRigPatch, ListBodyRigResponse, ListBodySessionBindingRecord, ListBodySessionResponse, ListBodyStatus, ListBodyWireEvent, LogicalNode, MailCountOutputBody, MailEventPayload, MailListBody, MailReplyInputBody, MailSendInputBody, MaintenanceRunBody, MaintenanceStatusBody, MaintenanceTriggerBody, Message, MoleculeResolvedPayload, MonitorFeedItemResponse, NoPayload, OkResponseBody, OkWithIdResponseBody, OptionChoiceDto, OrderCheckListBody, OrderCheckResponse, OrderGateTimeoutFailOpenPayload, OrderHistoryDetailResponse, OrderHistoryEntry, OrderHistoryListBody, OrderListBody, OrderResponse, OrderRunInputBody, OrderRunOutputBody, OrdersFeedBody, OutboundChannelMismatchPayload, OutboundEventPayload, OutboundResult, OutputTurn, PackAddedOutputBody, PackAddInputBody, PackListBody, PackRemovedOutputBody, PackResponse, PaginationInfo, PatchDeletedResponseBody, PatchOkResponseBody, PatchV0CityByCityNameAgentByBaseData, PatchV0CityByCityNameAgentByBaseError, PatchV0CityByCityNameAgentByBaseErrors, PatchV0CityByCityNameAgentByBaseResponse, PatchV0CityByCityNameAgentByBaseResponses, PatchV0CityByCityNameAgentByDirByBaseData, PatchV0CityByCityNameAgentByDirByBaseError, PatchV0CityByCityNameAgentByDirByBaseErrors, PatchV0CityByCityNameAgentByDirByBaseResponse, PatchV0CityByCityNameAgentByDirByBaseResponses, PatchV0CityByCityNameBeadByIdData, PatchV0CityByCityNameBeadByIdError, PatchV0CityByCityNameBeadByIdErrors, PatchV0CityByCityNameBeadByIdResponse, PatchV0CityByCityNameBeadByIdResponses, PatchV0CityByCityNameData, PatchV0CityByCityNameError, PatchV0CityByCityNameErrors, PatchV0CityByCityNameProviderByNameData, PatchV0CityByCityNameProviderByNameError, PatchV0CityByCityNameProviderByNameErrors, PatchV0CityByCityNameProviderByNameResponse, PatchV0CityByCityNameProviderByNameResponses, PatchV0CityByCityNameResponse, PatchV0CityByCityNameResponses, PatchV0CityByCityNameRigByNameData, PatchV0CityByCityNameRigByNameError, PatchV0CityByCityNameRigByNameErrors, PatchV0CityByCityNameRigByNameResponse, PatchV0CityByCityNameRigByNameResponses, PatchV0CityByCityNameSessionByIdData, PatchV0CityByCityNameSessionByIdError, PatchV0CityByCityNameSessionByIdErrors, PatchV0CityByCityNameSessionByIdResponse, PatchV0CityByCityNameSessionByIdResponses, PendingInteraction, PoolOverride, PostgresCredentialResolvedPayload, PostV0CityByCityNameAgentByBaseByActionData, PostV0CityByCityNameAgentByBaseByActionError, PostV0CityByCityNameAgentByBaseByActionErrors, PostV0CityByCityNameAgentByBaseByActionResponse, PostV0CityByCityNameAgentByBaseByActionResponses, PostV0CityByCityNameAgentByDirByBaseByActionData, PostV0CityByCityNameAgentByDirByBaseByActionError, PostV0CityByCityNameAgentByDirByBaseByActionErrors, PostV0CityByCityNameAgentByDirByBaseByActionResponse, PostV0CityByCityNameAgentByDirByBaseByActionResponses, PostV0CityByCityNameBeadByIdAssignData, PostV0CityByCityNameBeadByIdAssignError, PostV0CityByCityNameBeadByIdAssignErrors, PostV0CityByCityNameBeadByIdAssignResponse, PostV0CityByCityNameBeadByIdAssignResponses, PostV0CityByCityNameBeadByIdCloseData, PostV0CityByCityNameBeadByIdCloseError, PostV0CityByCityNameBeadByIdCloseErrors, PostV0CityByCityNameBeadByIdCloseResponse, PostV0CityByCityNameBeadByIdCloseResponses, PostV0CityByCityNameBeadByIdReopenData, PostV0CityByCityNameBeadByIdReopenError, PostV0CityByCityNameBeadByIdReopenErrors, PostV0CityByCityNameBeadByIdReopenResponse, PostV0CityByCityNameBeadByIdReopenResponses, PostV0CityByCityNameBeadByIdUpdateData, PostV0CityByCityNameBeadByIdUpdateError, PostV0CityByCityNameBeadByIdUpdateErrors, PostV0CityByCityNameBeadByIdUpdateResponse, PostV0CityByCityNameBeadByIdUpdateResponses, PostV0CityByCityNameConvoyByIdAddData, PostV0CityByCityNameConvoyByIdAddError, PostV0CityByCityNameConvoyByIdAddErrors, PostV0CityByCityNameConvoyByIdAddResponse, PostV0CityByCityNameConvoyByIdAddResponses, PostV0CityByCityNameConvoyByIdCloseData, PostV0CityByCityNameConvoyByIdCloseError, PostV0CityByCityNameConvoyByIdCloseErrors, PostV0CityByCityNameConvoyByIdCloseResponse, PostV0CityByCityNameConvoyByIdCloseResponses, PostV0CityByCityNameConvoyByIdRemoveData, PostV0CityByCityNameConvoyByIdRemoveError, PostV0CityByCityNameConvoyByIdRemoveErrors, PostV0CityByCityNameConvoyByIdRemoveResponse, PostV0CityByCityNameConvoyByIdRemoveResponses, PostV0CityByCityNameExtmsgBindData, PostV0CityByCityNameExtmsgBindError, PostV0CityByCityNameExtmsgBindErrors, PostV0CityByCityNameExtmsgBindResponse, PostV0CityByCityNameExtmsgBindResponses, PostV0CityByCityNameExtmsgInboundData, PostV0CityByCityNameExtmsgInboundError, PostV0CityByCityNameExtmsgInboundErrors, PostV0CityByCityNameExtmsgInboundResponse, PostV0CityByCityNameExtmsgInboundResponses, PostV0CityByCityNameExtmsgOutboundData, PostV0CityByCityNameExtmsgOutboundError, PostV0CityByCityNameExtmsgOutboundErrors, PostV0CityByCityNameExtmsgOutboundResponse, PostV0CityByCityNameExtmsgOutboundResponses, PostV0CityByCityNameExtmsgParticipantsData, PostV0CityByCityNameExtmsgParticipantsError, PostV0CityByCityNameExtmsgParticipantsErrors, PostV0CityByCityNameExtmsgParticipantsResponse, PostV0CityByCityNameExtmsgParticipantsResponses, PostV0CityByCityNameExtmsgTranscriptAckData, PostV0CityByCityNameExtmsgTranscriptAckError, PostV0CityByCityNameExtmsgTranscriptAckErrors, PostV0CityByCityNameExtmsgTranscriptAckResponse, PostV0CityByCityNameExtmsgTranscriptAckResponses, PostV0CityByCityNameExtmsgUnbindData, PostV0CityByCityNameExtmsgUnbindError, PostV0CityByCityNameExtmsgUnbindErrors, PostV0CityByCityNameExtmsgUnbindResponse, PostV0CityByCityNameExtmsgUnbindResponses, PostV0CityByCityNameFormulasByNamePreviewData, PostV0CityByCityNameFormulasByNamePreviewError, PostV0CityByCityNameFormulasByNamePreviewErrors, PostV0CityByCityNameFormulasByNamePreviewResponse, PostV0CityByCityNameFormulasByNamePreviewResponses, PostV0CityByCityNameFormulasByNameValidateData, PostV0CityByCityNameFormulasByNameValidateError, PostV0CityByCityNameFormulasByNameValidateErrors, PostV0CityByCityNameFormulasByNameValidateResponse, PostV0CityByCityNameFormulasByNameValidateResponses, PostV0CityByCityNameMailByIdArchiveData, PostV0CityByCityNameMailByIdArchiveError, PostV0CityByCityNameMailByIdArchiveErrors, PostV0CityByCityNameMailByIdArchiveResponse, PostV0CityByCityNameMailByIdArchiveResponses, PostV0CityByCityNameMailByIdMarkUnreadData, PostV0CityByCityNameMailByIdMarkUnreadError, PostV0CityByCityNameMailByIdMarkUnreadErrors, PostV0CityByCityNameMailByIdMarkUnreadResponse, PostV0CityByCityNameMailByIdMarkUnreadResponses, PostV0CityByCityNameMailByIdReadData, PostV0CityByCityNameMailByIdReadError, PostV0CityByCityNameMailByIdReadErrors, PostV0CityByCityNameMailByIdReadResponse, PostV0CityByCityNameMailByIdReadResponses, PostV0CityByCityNameOrderByNameDisableData, PostV0CityByCityNameOrderByNameDisableError, PostV0CityByCityNameOrderByNameDisableErrors, PostV0CityByCityNameOrderByNameDisableResponse, PostV0CityByCityNameOrderByNameDisableResponses, PostV0CityByCityNameOrderByNameEnableData, PostV0CityByCityNameOrderByNameEnableError, PostV0CityByCityNameOrderByNameEnableErrors, PostV0CityByCityNameOrderByNameEnableResponse, PostV0CityByCityNameOrderByNameEnableResponses, PostV0CityByCityNameOrderByNameRunData, PostV0CityByCityNameOrderByNameRunError, PostV0CityByCityNameOrderByNameRunErrors, PostV0CityByCityNameOrderByNameRunResponse, PostV0CityByCityNameOrderByNameRunResponses, PostV0CityByCityNameRigByNameByActionData, PostV0CityByCityNameRigByNameByActionError, PostV0CityByCityNameRigByNameByActionErrors, PostV0CityByCityNameRigByNameByActionResponse, PostV0CityByCityNameRigByNameByActionResponses, PostV0CityByCityNameRunsByRunIdCancelData, PostV0CityByCityNameRunsByRunIdCancelError, PostV0CityByCityNameRunsByRunIdCancelErrors, PostV0CityByCityNameRunsByRunIdCancelResponse, PostV0CityByCityNameRunsByRunIdCancelResponses, PostV0CityByCityNameServiceByNameRestartData, PostV0CityByCityNameServiceByNameRestartError, PostV0CityByCityNameServiceByNameRestartErrors, PostV0CityByCityNameServiceByNameRestartResponse, PostV0CityByCityNameServiceByNameRestartResponses, PostV0CityByCityNameSessionByIdCloseData, PostV0CityByCityNameSessionByIdCloseError, PostV0CityByCityNameSessionByIdCloseErrors, PostV0CityByCityNameSessionByIdCloseResponse, PostV0CityByCityNameSessionByIdCloseResponses, PostV0CityByCityNameSessionByIdKillData, PostV0CityByCityNameSessionByIdKillError, PostV0CityByCityNameSessionByIdKillErrors, PostV0CityByCityNameSessionByIdKillResponse, PostV0CityByCityNameSessionByIdKillResponses, PostV0CityByCityNameSessionByIdPermissionModeData, PostV0CityByCityNameSessionByIdPermissionModeError, PostV0CityByCityNameSessionByIdPermissionModeErrors, PostV0CityByCityNameSessionByIdPermissionModeResponse, PostV0CityByCityNameSessionByIdPermissionModeResponses, PostV0CityByCityNameSessionByIdRenameData, PostV0CityByCityNameSessionByIdRenameError, PostV0CityByCityNameSessionByIdRenameErrors, PostV0CityByCityNameSessionByIdRenameResponse, PostV0CityByCityNameSessionByIdRenameResponses, PostV0CityByCityNameSessionByIdStopData, PostV0CityByCityNameSessionByIdStopError, PostV0CityByCityNameSessionByIdStopErrors, PostV0CityByCityNameSessionByIdStopResponse, PostV0CityByCityNameSessionByIdStopResponses, PostV0CityByCityNameSessionByIdSuspendData, PostV0CityByCityNameSessionByIdSuspendError, PostV0CityByCityNameSessionByIdSuspendErrors, PostV0CityByCityNameSessionByIdSuspendResponse, PostV0CityByCityNameSessionByIdSuspendResponses, PostV0CityByCityNameSessionByIdWakeData, PostV0CityByCityNameSessionByIdWakeError, PostV0CityByCityNameSessionByIdWakeErrors, PostV0CityByCityNameSessionByIdWakeResponse, PostV0CityByCityNameSessionByIdWakeResponses, PostV0CityByCityNameSlingData, PostV0CityByCityNameSlingError, PostV0CityByCityNameSlingErrors, PostV0CityByCityNameSlingResponse, PostV0CityByCityNameSlingResponses, PostV0CityByCityNameUnregisterData, PostV0CityByCityNameUnregisterError, PostV0CityByCityNameUnregisterErrors, PostV0CityByCityNameUnregisterResponse, PostV0CityByCityNameUnregisterResponses, PostV0CityData, PostV0CityError, PostV0CityErrors, PostV0CityResponse, PostV0CityResponses, ProjectIdentityStampedPayload, ProviderCreatedOutputBody, ProviderCreateInputBody, ProviderOptionDto, ProviderPatch, ProviderPatchSetInputBody, ProviderPublicListBody, ProviderPublicResponse, ProviderReadiness, ProviderReadinessResponse, ProviderResponse, ProviderSpecJson, ProviderUpdateInputBody, ProxyReapedPayload, PublishReceipt, PutV0CityByCityNameFormulasByNameData, PutV0CityByCityNameFormulasByNameError, PutV0CityByCityNameFormulasByNameErrors, PutV0CityByCityNameFormulasByNameResponse, PutV0CityByCityNameFormulasByNameResponses, PutV0CityByCityNamePatchesAgentsData, PutV0CityByCityNamePatchesAgentsError, PutV0CityByCityNamePatchesAgentsErrors, PutV0CityByCityNamePatchesAgentsResponse, PutV0CityByCityNamePatchesAgentsResponses, PutV0CityByCityNamePatchesProvidersData, PutV0CityByCityNamePatchesProvidersError, PutV0CityByCityNamePatchesProvidersErrors, PutV0CityByCityNamePatchesProvidersResponse, PutV0CityByCityNamePatchesProvidersResponses, PutV0CityByCityNamePatchesRigsData, PutV0CityByCityNamePatchesRigsError, PutV0CityByCityNamePatchesRigsErrors, PutV0CityByCityNamePatchesRigsResponse, PutV0CityByCityNamePatchesRigsResponses, QuotaObservedPayload, QuotaPollFailedPayload, ReadinessItem, ReadinessResponse, Record, RegisterExtmsgAdapterData, RegisterExtmsgAdapterError, RegisterExtmsgAdapterErrors, RegisterExtmsgAdapterResponse, RegisterExtmsgAdapterResponses, ReplyMailData, ReplyMailError, ReplyMailErrors, ReplyMailResponse, ReplyMailResponses, RequestFailedPayload, RespondSessionData, RespondSessionError, RespondSessionErrors, RespondSessionResponse, RespondSessionResponses, RigActionBody, RigCreateBody, RigCreateResponseBody, RigCreateSucceededPayload, RigPatch, RigPatchSetInputBody, RigProvisionProgressPayload, RigResponse, RigUpdateInputBody, RotatedPayload, RotateEventsData, RotateEventsError, RotateEventsErrors, RotateEventsResponse, RotateEventsResponses, Run, RunCancelOutputBody, RunLastError, RunRef, RunsCensusOutputBody, RunScope, RunsListOutputBody, RunStatus, RunStatusCounts, RunStep, RunStepsOutputBody, RunStepStatus, ScopeGroup, SendMailData, SendMailError, SendMailErrors, SendMailResponse, SendMailResponses, SendSessionMessageData, SendSessionMessageError, SendSessionMessageErrors, SendSessionMessageResponse, SendSessionMessageResponses, ServiceRestartOutputBody, SessionActivityEvent, SessionAgentGetResponse, SessionAgentListResponse, SessionBindingRecord, SessionCreateBody, SessionCreateSucceededPayload, SessionDrainAckedWithAssignedWorkPayload, SessionInfo, SessionLifecyclePayload, SessionMessageInputBody, SessionMessageSucceededPayload, SessionPatchBody, SessionPendingClearedEvent, SessionPendingResponse, SessionPermissionModeBody, SessionRawMessageFrame, SessionRenameInputBody, SessionResetStalledPayload, SessionRespondInputBody, SessionRespondOutputBody, SessionResponse, SessionStrandedPayload, SessionStreamCommonEvent, SessionStreamMessageEvent, SessionStreamRawMessageEvent, SessionStreamStructuredMessageEvent, SessionStructuredArgument, SessionStructuredBlock, SessionStructuredBlockImage, SessionStructuredBlockInteraction, SessionStructuredBlockText, SessionStructuredBlockThinking, SessionStructuredBlockToolResult, SessionStructuredBlockToolUse, SessionStructuredBlockUnknown, SessionStructuredContinuity, SessionStructuredCursor, SessionStructuredDiagnostic, SessionStructuredGeneration, SessionStructuredHistory, SessionStructuredIdeSelection, SessionStructuredInteraction, SessionStructuredMessage, SessionStructuredMessageAssistant, SessionStructuredMessageSystem, SessionStructuredMessageTool, SessionStructuredMessageUnknown, SessionStructuredMessageUser, SessionStructuredPatchHunk, SessionStructuredPlanStep, SessionStructuredQuestion, SessionStructuredQuestionOption, SessionStructuredSearchResultItem, SessionStructuredSystemEvent, SessionStructuredTailState, SessionStructuredTodoItem, SessionStructuredToolError, SessionStructuredToolInput, SessionStructuredToolInputArguments, SessionStructuredToolInputCode, SessionStructuredToolInputCommand, SessionStructuredToolInputFetch, SessionStructuredToolInputFile, SessionStructuredToolInputGlob, SessionStructuredToolInputPatch, SessionStructuredToolInputPlan, SessionStructuredToolInputQuestion, SessionStructuredToolInputSearch, SessionStructuredToolInputStdin, SessionStructuredToolInputTask, SessionStructuredToolInputText, SessionStructuredToolInputTodo, SessionStructuredToolInputUnknown, SessionStructuredToolInputWrite, SessionStructuredToolResult, SessionStructuredToolResultBash, SessionStructuredToolResultEdit, SessionStructuredToolResultFetch, SessionStructuredToolResultGlob, SessionStructuredToolResultGrep, SessionStructuredToolResultPlan, SessionStructuredToolResultPython, SessionStructuredToolResultQuestion, SessionStructuredToolResultRead, SessionStructuredToolResultSearch, SessionStructuredToolResultStdin, SessionStructuredToolResultTask, SessionStructuredToolResultText, SessionStructuredToolResultTodo, SessionStructuredToolResultUnknown, SessionStructuredToolResultWrite, SessionStructuredUploadedFile, SessionStructuredUsage, SessionStructuredUserPrompt, SessionSubmitInputBody, SessionSubmitSucceededPayload, SessionTranscriptConversationResponse, SessionTranscriptGetResponse, SessionTranscriptRawResponse, SessionTranscriptStructuredResponse, SessionUnknownStatePayload, SlingInputBody, SlingResponse, Status, StatusAgentCounts, StatusAgentDetail, StatusBody, StatusConditionalWrites, StatusConditionalWriteStoreVerdict, StatusMailCounts, StatusNamedSessionDetail, StatusRigCounts, StatusRigDetail, StatusRolloutNotice, StatusSessionCountsDetail, StatusStoreHealth, StatusWorkCounts, StoreDegradedPayload, StoreDiskCriticalPayload, StoreDiskWarnPayload, StoreMaintenanceDonePayload, StoreMaintenanceFailedPayload, StoreProbeFailedPayload, StoreRecoveredPayload, StreamAgentOutputData, StreamAgentOutputError, StreamAgentOutputErrors, StreamAgentOutputQualifiedData, StreamAgentOutputQualifiedError, StreamAgentOutputQualifiedErrors, StreamAgentOutputQualifiedResponse, StreamAgentOutputQualifiedResponses, StreamAgentOutputResponse, StreamAgentOutputResponses, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, StreamSessionData, StreamSessionError, StreamSessionErrors, StreamSessionResponse, StreamSessionResponses, StreamSupervisorEventsData, StreamSupervisorEventsError, StreamSupervisorEventsErrors, StreamSupervisorEventsResponse, StreamSupervisorEventsResponses, SubmissionCapabilities, SubmitIntent, SubmitSessionData, SubmitSessionError, SubmitSessionErrors, SubmitSessionResponse, SubmitSessionResponses, SupervisorCitiesOutputBody, SupervisorEventListOutputBody, SupervisorFsPressureSkippedTickPayload, SupervisorHealthOutputBody, SupervisorRequestPayload, SupervisorShutdownPayload, SupervisorStartedPayload, SupervisorStartup, TaggedEventStreamEnvelope, TranscriptMessageKind, TranscriptProvenance, TriggerMaintenanceDoltGcData, TriggerMaintenanceDoltGcError, TriggerMaintenanceDoltGcErrors, TriggerMaintenanceDoltGcResponse, TriggerMaintenanceDoltGcResponses, TypedEventStreamEnvelope, TypedEventStreamEnvelopeBeadClaimRejected, TypedEventStreamEnvelopeBeadClosed, TypedEventStreamEnvelopeBeadCreated, TypedEventStreamEnvelopeBeadDeadAssigneeReopened, TypedEventStreamEnvelopeBeadDeleted, TypedEventStreamEnvelopeBeadsConditionalWritesDegraded, TypedEventStreamEnvelopeBeadUpdated, TypedEventStreamEnvelopeBeadWorktreeReaped, TypedEventStreamEnvelopeBeadWorktreeReapSkipped, TypedEventStreamEnvelopeBreakerStateChanged, TypedEventStreamEnvelopeCityCreated, TypedEventStreamEnvelopeCityResumed, TypedEventStreamEnvelopeCitySuspended, TypedEventStreamEnvelopeCityUnregisterRequested, TypedEventStreamEnvelopeControllerStarted, TypedEventStreamEnvelopeControllerStopped, TypedEventStreamEnvelopeControllerTickCompleted, TypedEventStreamEnvelopeConvoyClosed, TypedEventStreamEnvelopeConvoyCreated, TypedEventStreamEnvelopeCustom, TypedEventStreamEnvelopeDoctorAlert, TypedEventStreamEnvelopeEmergencyAcked, TypedEventStreamEnvelopeEmergencySignaled, TypedEventStreamEnvelopeEventsRotated, TypedEventStreamEnvelopeExtmsgAdapterAdded, TypedEventStreamEnvelopeExtmsgAdapterRemoved, TypedEventStreamEnvelopeExtmsgBound, TypedEventStreamEnvelopeExtmsgGroupCreated, TypedEventStreamEnvelopeExtmsgInbound, TypedEventStreamEnvelopeExtmsgOutbound, TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch, TypedEventStreamEnvelopeExtmsgUnbound, TypedEventStreamEnvelopeGcStoreDiskCritical, TypedEventStreamEnvelopeGcStoreDiskWarn, TypedEventStreamEnvelopeGcStoreMaintenanceDone, TypedEventStreamEnvelopeGcStoreMaintenanceFailed, TypedEventStreamEnvelopeMailArchived, TypedEventStreamEnvelopeMailDeleted, TypedEventStreamEnvelopeMailMarkedRead, TypedEventStreamEnvelopeMailMarkedUnread, TypedEventStreamEnvelopeMailRead, TypedEventStreamEnvelopeMailReplied, TypedEventStreamEnvelopeMailSent, TypedEventStreamEnvelopeMoleculeResolved, TypedEventStreamEnvelopeOrderCompleted, TypedEventStreamEnvelopeOrderFailed, TypedEventStreamEnvelopeOrderFired, TypedEventStreamEnvelopeOrderGateTimeoutFailOpen, TypedEventStreamEnvelopePgCredentialResolved, TypedEventStreamEnvelopeProjectIdentityStamped, TypedEventStreamEnvelopeProviderQuotaObserved, TypedEventStreamEnvelopeProviderQuotaPollFailed, TypedEventStreamEnvelopeProviderSwapped, TypedEventStreamEnvelopeProxyReaped, TypedEventStreamEnvelopeRequestFailed, TypedEventStreamEnvelopeRequestResultCityCreate, TypedEventStreamEnvelopeRequestResultCityUnregister, TypedEventStreamEnvelopeRequestResultRigCreate, TypedEventStreamEnvelopeRequestResultSessionCreate, TypedEventStreamEnvelopeRequestResultSessionMessage, TypedEventStreamEnvelopeRequestResultSessionSubmit, TypedEventStreamEnvelopeRigProvisionProgress, TypedEventStreamEnvelopeSessionColdStartTimeout, TypedEventStreamEnvelopeSessionCrashed, TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork, TypedEventStreamEnvelopeSessionDraining, TypedEventStreamEnvelopeSessionIdleKilled, TypedEventStreamEnvelopeSessionMaxAgeKilled, TypedEventStreamEnvelopeSessionQuarantined, TypedEventStreamEnvelopeSessionResetStalled, TypedEventStreamEnvelopeSessionStopped, TypedEventStreamEnvelopeSessionStranded, TypedEventStreamEnvelopeSessionSuspended, TypedEventStreamEnvelopeSessionUndrained, TypedEventStreamEnvelopeSessionUnknownState, TypedEventStreamEnvelopeSessionUpdated, TypedEventStreamEnvelopeSessionWoke, TypedEventStreamEnvelopeSessionWorkQueryFailed, TypedEventStreamEnvelopeStoreDegraded, TypedEventStreamEnvelopeStoreProbeFailed, TypedEventStreamEnvelopeStoreRecovered, TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick, TypedEventStreamEnvelopeSupervisorRequest, TypedEventStreamEnvelopeSupervisorShutdownRequested, TypedEventStreamEnvelopeSupervisorStarted, TypedEventStreamEnvelopeWebhookReceived, TypedEventStreamEnvelopeWebhookRejected, TypedEventStreamEnvelopeWorkerOperation, TypedTaggedEventStreamEnvelope, TypedTaggedEventStreamEnvelopeBeadClaimRejected, TypedTaggedEventStreamEnvelopeBeadClosed, TypedTaggedEventStreamEnvelopeBeadCreated, TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened, TypedTaggedEventStreamEnvelopeBeadDeleted, TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded, TypedTaggedEventStreamEnvelopeBeadUpdated, TypedTaggedEventStreamEnvelopeBeadWorktreeReaped, TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped, TypedTaggedEventStreamEnvelopeBreakerStateChanged, TypedTaggedEventStreamEnvelopeCityCreated, TypedTaggedEventStreamEnvelopeCityResumed, TypedTaggedEventStreamEnvelopeCitySuspended, TypedTaggedEventStreamEnvelopeCityUnregisterRequested, TypedTaggedEventStreamEnvelopeControllerStarted, TypedTaggedEventStreamEnvelopeControllerStopped, TypedTaggedEventStreamEnvelopeControllerTickCompleted, TypedTaggedEventStreamEnvelopeConvoyClosed, TypedTaggedEventStreamEnvelopeConvoyCreated, TypedTaggedEventStreamEnvelopeCustom, TypedTaggedEventStreamEnvelopeDoctorAlert, TypedTaggedEventStreamEnvelopeEmergencyAcked, TypedTaggedEventStreamEnvelopeEmergencySignaled, TypedTaggedEventStreamEnvelopeEventsRotated, TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded, TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved, TypedTaggedEventStreamEnvelopeExtmsgBound, TypedTaggedEventStreamEnvelopeExtmsgGroupCreated, TypedTaggedEventStreamEnvelopeExtmsgInbound, TypedTaggedEventStreamEnvelopeExtmsgOutbound, TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch, TypedTaggedEventStreamEnvelopeExtmsgUnbound, TypedTaggedEventStreamEnvelopeGcStoreDiskCritical, TypedTaggedEventStreamEnvelopeGcStoreDiskWarn, TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone, TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed, TypedTaggedEventStreamEnvelopeMailArchived, TypedTaggedEventStreamEnvelopeMailDeleted, TypedTaggedEventStreamEnvelopeMailMarkedRead, TypedTaggedEventStreamEnvelopeMailMarkedUnread, TypedTaggedEventStreamEnvelopeMailRead, TypedTaggedEventStreamEnvelopeMailReplied, TypedTaggedEventStreamEnvelopeMailSent, TypedTaggedEventStreamEnvelopeMoleculeResolved, TypedTaggedEventStreamEnvelopeOrderCompleted, TypedTaggedEventStreamEnvelopeOrderFailed, TypedTaggedEventStreamEnvelopeOrderFired, TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen, TypedTaggedEventStreamEnvelopePgCredentialResolved, TypedTaggedEventStreamEnvelopeProjectIdentityStamped, TypedTaggedEventStreamEnvelopeProviderQuotaObserved, TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed, TypedTaggedEventStreamEnvelopeProviderSwapped, TypedTaggedEventStreamEnvelopeProxyReaped, TypedTaggedEventStreamEnvelopeRequestFailed, TypedTaggedEventStreamEnvelopeRequestResultCityCreate, TypedTaggedEventStreamEnvelopeRequestResultCityUnregister, TypedTaggedEventStreamEnvelopeRequestResultRigCreate, TypedTaggedEventStreamEnvelopeRequestResultSessionCreate, TypedTaggedEventStreamEnvelopeRequestResultSessionMessage, TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit, TypedTaggedEventStreamEnvelopeRigProvisionProgress, TypedTaggedEventStreamEnvelopeSessionColdStartTimeout, TypedTaggedEventStreamEnvelopeSessionCrashed, TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork, TypedTaggedEventStreamEnvelopeSessionDraining, TypedTaggedEventStreamEnvelopeSessionIdleKilled, TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled, TypedTaggedEventStreamEnvelopeSessionQuarantined, TypedTaggedEventStreamEnvelopeSessionResetStalled, TypedTaggedEventStreamEnvelopeSessionStopped, TypedTaggedEventStreamEnvelopeSessionStranded, TypedTaggedEventStreamEnvelopeSessionSuspended, TypedTaggedEventStreamEnvelopeSessionUndrained, TypedTaggedEventStreamEnvelopeSessionUnknownState, TypedTaggedEventStreamEnvelopeSessionUpdated, TypedTaggedEventStreamEnvelopeSessionWoke, TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed, TypedTaggedEventStreamEnvelopeStoreDegraded, TypedTaggedEventStreamEnvelopeStoreProbeFailed, TypedTaggedEventStreamEnvelopeStoreRecovered, TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick, TypedTaggedEventStreamEnvelopeSupervisorRequest, TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested, TypedTaggedEventStreamEnvelopeSupervisorStarted, TypedTaggedEventStreamEnvelopeWebhookReceived, TypedTaggedEventStreamEnvelopeWebhookRejected, TypedTaggedEventStreamEnvelopeWorkerOperation, UnboundEventPayload, UsageBody, UsageSessionRecent, UsageTotals, WaitListBody, WaitView, WebhookReceivedPayload, WebhookRejectedPayload, WorkerOperationEventPayload, WorkflowAttemptSummary, WorkflowBeadResponse, WorkflowDeleteResponse, WorkflowDepResponse, WorkflowEventProjection, WorkflowSnapshotResponse, WorkspaceResponse } from './types.gen.js';
+export type { AdapterCapabilities, AdapterEventPayload, AddPackData, AddPackError, AddPackErrors, AddPackResponse, AddPackResponses, AgentCreatedOutputBody, AgentCreateInputBody, AgentMapping, AgentOutputResponse, AgentPatch, AgentPatchSetInputBody, AgentResponse, AgentUpdateInputBody, AgentUpdateQualifiedInputBody, AnnotatedAgentResponse, AnnotatedProviderResponse, AsyncAcceptedBody, AsyncAcceptedResponse, Bead, BeadAssignInputBody, BeadClaimRejectedPayload, BeadCreateInputBody, BeadDeadAssigneeReopenedPayload, BeadDepsResponse, BeadEventPayload, BeadGraphResponse, BeadsDiagnostic, BeadUpdateBody, BeadWorktreeReapedPayload, BeadWorktreeReapSkippedPayload, BindingStatus, BoundEventPayload, BreakerStateChangedPayload, CityCreateRequest, CityCreateSucceededPayload, CityGetResponse, CityInfo, CityLifecyclePayload, CityPatchInputBody, CityPendingEntry, CityUnregisterSucceededPayload, ClientOptions, ConditionalWritesDegradedPayload, ConfigAgentResponse, ConfigExplainPatches, ConfigExplainResponse, ConfigPatchesResponse, ConfigResponse, ConfigRigResponse, ConfigValidateOutputBody, ControllerTickCompletedPayload, ConversationGroupParticipant, ConversationGroupRecord, ConversationKind, ConversationRef, ConversationTranscriptRecord, ConvoyAddInputBody, ConvoyCheckResponse, ConvoyCreateInputBody, ConvoyGetResponse, ConvoyProgress, ConvoyRemoveInputBody, CreateAgentData, CreateAgentError, CreateAgentErrors, CreateAgentResponse, CreateAgentResponses, CreateBeadData, CreateBeadError, CreateBeadErrors, CreateBeadResponse, CreateBeadResponses, CreateConvoyData, CreateConvoyError, CreateConvoyErrors, CreateConvoyResponse, CreateConvoyResponses, CreateProviderData, CreateProviderError, CreateProviderErrors, CreateProviderResponse, CreateProviderResponses, CreateRigData, CreateRigError, CreateRigErrors, CreateRigResponse, CreateRigResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionResponse, CreateSessionResponses, DeleteV0CityByCityNameAgentByBaseData, DeleteV0CityByCityNameAgentByBaseError, DeleteV0CityByCityNameAgentByBaseErrors, DeleteV0CityByCityNameAgentByBaseResponse, DeleteV0CityByCityNameAgentByBaseResponses, DeleteV0CityByCityNameAgentByDirByBaseData, DeleteV0CityByCityNameAgentByDirByBaseError, DeleteV0CityByCityNameAgentByDirByBaseErrors, DeleteV0CityByCityNameAgentByDirByBaseResponse, DeleteV0CityByCityNameAgentByDirByBaseResponses, DeleteV0CityByCityNameBeadByIdData, DeleteV0CityByCityNameBeadByIdError, DeleteV0CityByCityNameBeadByIdErrors, DeleteV0CityByCityNameBeadByIdResponse, DeleteV0CityByCityNameBeadByIdResponses, DeleteV0CityByCityNameConvoyByIdData, DeleteV0CityByCityNameConvoyByIdError, DeleteV0CityByCityNameConvoyByIdErrors, DeleteV0CityByCityNameConvoyByIdResponse, DeleteV0CityByCityNameConvoyByIdResponses, DeleteV0CityByCityNameExtmsgAdaptersData, DeleteV0CityByCityNameExtmsgAdaptersError, DeleteV0CityByCityNameExtmsgAdaptersErrors, DeleteV0CityByCityNameExtmsgAdaptersResponse, DeleteV0CityByCityNameExtmsgAdaptersResponses, DeleteV0CityByCityNameExtmsgParticipantsData, DeleteV0CityByCityNameExtmsgParticipantsError, DeleteV0CityByCityNameExtmsgParticipantsErrors, DeleteV0CityByCityNameExtmsgParticipantsResponse, DeleteV0CityByCityNameExtmsgParticipantsResponses, DeleteV0CityByCityNameFormulasByNameData, DeleteV0CityByCityNameFormulasByNameError, DeleteV0CityByCityNameFormulasByNameErrors, DeleteV0CityByCityNameFormulasByNameResponse, DeleteV0CityByCityNameFormulasByNameResponses, DeleteV0CityByCityNameMailByIdData, DeleteV0CityByCityNameMailByIdError, DeleteV0CityByCityNameMailByIdErrors, DeleteV0CityByCityNameMailByIdResponse, DeleteV0CityByCityNameMailByIdResponses, DeleteV0CityByCityNamePacksByNameData, DeleteV0CityByCityNamePacksByNameError, DeleteV0CityByCityNamePacksByNameErrors, DeleteV0CityByCityNamePacksByNameResponse, DeleteV0CityByCityNamePacksByNameResponses, DeleteV0CityByCityNamePatchesAgentByBaseData, DeleteV0CityByCityNamePatchesAgentByBaseError, DeleteV0CityByCityNamePatchesAgentByBaseErrors, DeleteV0CityByCityNamePatchesAgentByBaseResponse, DeleteV0CityByCityNamePatchesAgentByBaseResponses, DeleteV0CityByCityNamePatchesAgentByDirByBaseData, DeleteV0CityByCityNamePatchesAgentByDirByBaseError, DeleteV0CityByCityNamePatchesAgentByDirByBaseErrors, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse, DeleteV0CityByCityNamePatchesAgentByDirByBaseResponses, DeleteV0CityByCityNamePatchesProviderByNameData, DeleteV0CityByCityNamePatchesProviderByNameError, DeleteV0CityByCityNamePatchesProviderByNameErrors, DeleteV0CityByCityNamePatchesProviderByNameResponse, DeleteV0CityByCityNamePatchesProviderByNameResponses, DeleteV0CityByCityNamePatchesRigByNameData, DeleteV0CityByCityNamePatchesRigByNameError, DeleteV0CityByCityNamePatchesRigByNameErrors, DeleteV0CityByCityNamePatchesRigByNameResponse, DeleteV0CityByCityNamePatchesRigByNameResponses, DeleteV0CityByCityNameProviderByNameData, DeleteV0CityByCityNameProviderByNameError, DeleteV0CityByCityNameProviderByNameErrors, DeleteV0CityByCityNameProviderByNameResponse, DeleteV0CityByCityNameProviderByNameResponses, DeleteV0CityByCityNameRigByNameData, DeleteV0CityByCityNameRigByNameError, DeleteV0CityByCityNameRigByNameErrors, DeleteV0CityByCityNameRigByNameResponse, DeleteV0CityByCityNameRigByNameResponses, DeleteV0CityByCityNameWorkflowByWorkflowIdData, DeleteV0CityByCityNameWorkflowByWorkflowIdError, DeleteV0CityByCityNameWorkflowByWorkflowIdErrors, DeleteV0CityByCityNameWorkflowByWorkflowIdResponse, DeleteV0CityByCityNameWorkflowByWorkflowIdResponses, DeliveryContextRecord, Dep, DoctorAlertPayload, EmitEventData, EmitEventError, EmitEventErrors, EmitEventResponse, EmitEventResponses, EnsureExtmsgGroupData, EnsureExtmsgGroupError, EnsureExtmsgGroupErrors, EnsureExtmsgGroupResponse, EnsureExtmsgGroupResponses, ErrorDetail, ErrorModel, EventEmitOutputBody, EventEmitRequest, EventPayload, EventRotateAnchor, EventRotateArchive, EventRotateResponse, EventStreamEnvelope, ExternalActor, ExternalAttachment, ExternalInboundMessage, ExtmsgAdapterInfo, ExtMsgAdapterRegisterInputBody, ExtMsgAdapterRegisterOutputBody, ExtMsgAdapterUnregisterInputBody, ExtMsgBindInputBody, ExtMsgGroupEnsureInputBody, ExtMsgInboundInputBody, ExtMsgOutboundInputBody, ExtMsgParticipantRemoveInputBody, ExtMsgParticipantUpsertInputBody, ExtMsgTranscriptAckInputBody, ExtMsgUnbindBody, ExtMsgUnbindInputBody, FanoutPolicy, FormulaDetailResponse, FormulaFeedBody, FormulaListBody, FormulaPreviewBody, FormulaPreviewEdgeResponse, FormulaPreviewNodeResponse, FormulaPreviewResponse, FormulaRecentRunResponse, FormulaRunsResponse, FormulaSourceOutputBody, FormulaStepResponse, FormulaSummaryResponse, FormulaValidateOutputBody, FormulaVarDefResponse, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetV0CitiesData, GetV0CitiesError, GetV0CitiesErrors, GetV0CitiesResponse, GetV0CitiesResponses, GetV0CityByCityNameAgentByBaseData, GetV0CityByCityNameAgentByBaseError, GetV0CityByCityNameAgentByBaseErrors, GetV0CityByCityNameAgentByBaseOutputData, GetV0CityByCityNameAgentByBaseOutputError, GetV0CityByCityNameAgentByBaseOutputErrors, GetV0CityByCityNameAgentByBaseOutputResponse, GetV0CityByCityNameAgentByBaseOutputResponses, GetV0CityByCityNameAgentByBaseResponse, GetV0CityByCityNameAgentByBaseResponses, GetV0CityByCityNameAgentByDirByBaseData, GetV0CityByCityNameAgentByDirByBaseError, GetV0CityByCityNameAgentByDirByBaseErrors, GetV0CityByCityNameAgentByDirByBaseOutputData, GetV0CityByCityNameAgentByDirByBaseOutputError, GetV0CityByCityNameAgentByDirByBaseOutputErrors, GetV0CityByCityNameAgentByDirByBaseOutputResponse, GetV0CityByCityNameAgentByDirByBaseOutputResponses, GetV0CityByCityNameAgentByDirByBaseResponse, GetV0CityByCityNameAgentByDirByBaseResponses, GetV0CityByCityNameAgentsData, GetV0CityByCityNameAgentsError, GetV0CityByCityNameAgentsErrors, GetV0CityByCityNameAgentsResponse, GetV0CityByCityNameAgentsResponses, GetV0CityByCityNameBeadByIdData, GetV0CityByCityNameBeadByIdDepsData, GetV0CityByCityNameBeadByIdDepsError, GetV0CityByCityNameBeadByIdDepsErrors, GetV0CityByCityNameBeadByIdDepsResponse, GetV0CityByCityNameBeadByIdDepsResponses, GetV0CityByCityNameBeadByIdError, GetV0CityByCityNameBeadByIdErrors, GetV0CityByCityNameBeadByIdResponse, GetV0CityByCityNameBeadByIdResponses, GetV0CityByCityNameBeadsData, GetV0CityByCityNameBeadsError, GetV0CityByCityNameBeadsErrors, GetV0CityByCityNameBeadsGraphByRootIdData, GetV0CityByCityNameBeadsGraphByRootIdError, GetV0CityByCityNameBeadsGraphByRootIdErrors, GetV0CityByCityNameBeadsGraphByRootIdResponse, GetV0CityByCityNameBeadsGraphByRootIdResponses, GetV0CityByCityNameBeadsReadyData, GetV0CityByCityNameBeadsReadyError, GetV0CityByCityNameBeadsReadyErrors, GetV0CityByCityNameBeadsReadyResponse, GetV0CityByCityNameBeadsReadyResponses, GetV0CityByCityNameBeadsResponse, GetV0CityByCityNameBeadsResponses, GetV0CityByCityNameConfigData, GetV0CityByCityNameConfigDefaultsData, GetV0CityByCityNameConfigDefaultsError, GetV0CityByCityNameConfigDefaultsErrors, GetV0CityByCityNameConfigDefaultsResponse, GetV0CityByCityNameConfigDefaultsResponses, GetV0CityByCityNameConfigError, GetV0CityByCityNameConfigErrors, GetV0CityByCityNameConfigExplainData, GetV0CityByCityNameConfigExplainError, GetV0CityByCityNameConfigExplainErrors, GetV0CityByCityNameConfigExplainResponse, GetV0CityByCityNameConfigExplainResponses, GetV0CityByCityNameConfigResponse, GetV0CityByCityNameConfigResponses, GetV0CityByCityNameConfigValidateData, GetV0CityByCityNameConfigValidateError, GetV0CityByCityNameConfigValidateErrors, GetV0CityByCityNameConfigValidateResponse, GetV0CityByCityNameConfigValidateResponses, GetV0CityByCityNameConvoyByIdCheckData, GetV0CityByCityNameConvoyByIdCheckError, GetV0CityByCityNameConvoyByIdCheckErrors, GetV0CityByCityNameConvoyByIdCheckResponse, GetV0CityByCityNameConvoyByIdCheckResponses, GetV0CityByCityNameConvoyByIdData, GetV0CityByCityNameConvoyByIdError, GetV0CityByCityNameConvoyByIdErrors, GetV0CityByCityNameConvoyByIdResponse, GetV0CityByCityNameConvoyByIdResponses, GetV0CityByCityNameConvoysData, GetV0CityByCityNameConvoysError, GetV0CityByCityNameConvoysErrors, GetV0CityByCityNameConvoysResponse, GetV0CityByCityNameConvoysResponses, GetV0CityByCityNameData, GetV0CityByCityNameError, GetV0CityByCityNameErrors, GetV0CityByCityNameEventsData, GetV0CityByCityNameEventsError, GetV0CityByCityNameEventsErrors, GetV0CityByCityNameEventsResponse, GetV0CityByCityNameEventsResponses, GetV0CityByCityNameExtmsgAdaptersData, GetV0CityByCityNameExtmsgAdaptersError, GetV0CityByCityNameExtmsgAdaptersErrors, GetV0CityByCityNameExtmsgAdaptersResponse, GetV0CityByCityNameExtmsgAdaptersResponses, GetV0CityByCityNameExtmsgBindingsData, GetV0CityByCityNameExtmsgBindingsError, GetV0CityByCityNameExtmsgBindingsErrors, GetV0CityByCityNameExtmsgBindingsResponse, GetV0CityByCityNameExtmsgBindingsResponses, GetV0CityByCityNameExtmsgGroupsData, GetV0CityByCityNameExtmsgGroupsError, GetV0CityByCityNameExtmsgGroupsErrors, GetV0CityByCityNameExtmsgGroupsResponse, GetV0CityByCityNameExtmsgGroupsResponses, GetV0CityByCityNameExtmsgTranscriptData, GetV0CityByCityNameExtmsgTranscriptError, GetV0CityByCityNameExtmsgTranscriptErrors, GetV0CityByCityNameExtmsgTranscriptResponse, GetV0CityByCityNameExtmsgTranscriptResponses, GetV0CityByCityNameFormulaByNameData, GetV0CityByCityNameFormulaByNameError, GetV0CityByCityNameFormulaByNameErrors, GetV0CityByCityNameFormulaByNameResponse, GetV0CityByCityNameFormulaByNameResponses, GetV0CityByCityNameFormulasByNameData, GetV0CityByCityNameFormulasByNameError, GetV0CityByCityNameFormulasByNameErrors, GetV0CityByCityNameFormulasByNameResponse, GetV0CityByCityNameFormulasByNameResponses, GetV0CityByCityNameFormulasByNameRunsData, GetV0CityByCityNameFormulasByNameRunsError, GetV0CityByCityNameFormulasByNameRunsErrors, GetV0CityByCityNameFormulasByNameRunsResponse, GetV0CityByCityNameFormulasByNameRunsResponses, GetV0CityByCityNameFormulasByNameSourceData, GetV0CityByCityNameFormulasByNameSourceError, GetV0CityByCityNameFormulasByNameSourceErrors, GetV0CityByCityNameFormulasByNameSourceResponse, GetV0CityByCityNameFormulasByNameSourceResponses, GetV0CityByCityNameFormulasData, GetV0CityByCityNameFormulasError, GetV0CityByCityNameFormulasErrors, GetV0CityByCityNameFormulasFeedData, GetV0CityByCityNameFormulasFeedError, GetV0CityByCityNameFormulasFeedErrors, GetV0CityByCityNameFormulasFeedResponse, GetV0CityByCityNameFormulasFeedResponses, GetV0CityByCityNameFormulasResponse, GetV0CityByCityNameFormulasResponses, GetV0CityByCityNameHealthData, GetV0CityByCityNameHealthError, GetV0CityByCityNameHealthErrors, GetV0CityByCityNameHealthResponse, GetV0CityByCityNameHealthResponses, GetV0CityByCityNameMailByIdData, GetV0CityByCityNameMailByIdError, GetV0CityByCityNameMailByIdErrors, GetV0CityByCityNameMailByIdResponse, GetV0CityByCityNameMailByIdResponses, GetV0CityByCityNameMailCountData, GetV0CityByCityNameMailCountError, GetV0CityByCityNameMailCountErrors, GetV0CityByCityNameMailCountResponse, GetV0CityByCityNameMailCountResponses, GetV0CityByCityNameMailData, GetV0CityByCityNameMailError, GetV0CityByCityNameMailErrors, GetV0CityByCityNameMailResponse, GetV0CityByCityNameMailResponses, GetV0CityByCityNameMailThreadByIdData, GetV0CityByCityNameMailThreadByIdError, GetV0CityByCityNameMailThreadByIdErrors, GetV0CityByCityNameMailThreadByIdResponse, GetV0CityByCityNameMailThreadByIdResponses, GetV0CityByCityNameMaintenanceStatusData, GetV0CityByCityNameMaintenanceStatusError, GetV0CityByCityNameMaintenanceStatusErrors, GetV0CityByCityNameMaintenanceStatusResponse, GetV0CityByCityNameMaintenanceStatusResponses, GetV0CityByCityNameOrderByNameData, GetV0CityByCityNameOrderByNameError, GetV0CityByCityNameOrderByNameErrors, GetV0CityByCityNameOrderByNameResponse, GetV0CityByCityNameOrderByNameResponses, GetV0CityByCityNameOrderHistoryByBeadIdData, GetV0CityByCityNameOrderHistoryByBeadIdError, GetV0CityByCityNameOrderHistoryByBeadIdErrors, GetV0CityByCityNameOrderHistoryByBeadIdResponse, GetV0CityByCityNameOrderHistoryByBeadIdResponses, GetV0CityByCityNameOrdersCheckData, GetV0CityByCityNameOrdersCheckError, GetV0CityByCityNameOrdersCheckErrors, GetV0CityByCityNameOrdersCheckResponse, GetV0CityByCityNameOrdersCheckResponses, GetV0CityByCityNameOrdersData, GetV0CityByCityNameOrdersError, GetV0CityByCityNameOrdersErrors, GetV0CityByCityNameOrdersFeedData, GetV0CityByCityNameOrdersFeedError, GetV0CityByCityNameOrdersFeedErrors, GetV0CityByCityNameOrdersFeedResponse, GetV0CityByCityNameOrdersFeedResponses, GetV0CityByCityNameOrdersHistoryData, GetV0CityByCityNameOrdersHistoryError, GetV0CityByCityNameOrdersHistoryErrors, GetV0CityByCityNameOrdersHistoryResponse, GetV0CityByCityNameOrdersHistoryResponses, GetV0CityByCityNameOrdersResponse, GetV0CityByCityNameOrdersResponses, GetV0CityByCityNamePacksData, GetV0CityByCityNamePacksError, GetV0CityByCityNamePacksErrors, GetV0CityByCityNamePacksResponse, GetV0CityByCityNamePacksResponses, GetV0CityByCityNamePatchesAgentByBaseData, GetV0CityByCityNamePatchesAgentByBaseError, GetV0CityByCityNamePatchesAgentByBaseErrors, GetV0CityByCityNamePatchesAgentByBaseResponse, GetV0CityByCityNamePatchesAgentByBaseResponses, GetV0CityByCityNamePatchesAgentByDirByBaseData, GetV0CityByCityNamePatchesAgentByDirByBaseError, GetV0CityByCityNamePatchesAgentByDirByBaseErrors, GetV0CityByCityNamePatchesAgentByDirByBaseResponse, GetV0CityByCityNamePatchesAgentByDirByBaseResponses, GetV0CityByCityNamePatchesAgentsData, GetV0CityByCityNamePatchesAgentsError, GetV0CityByCityNamePatchesAgentsErrors, GetV0CityByCityNamePatchesAgentsResponse, GetV0CityByCityNamePatchesAgentsResponses, GetV0CityByCityNamePatchesProviderByNameData, GetV0CityByCityNamePatchesProviderByNameError, GetV0CityByCityNamePatchesProviderByNameErrors, GetV0CityByCityNamePatchesProviderByNameResponse, GetV0CityByCityNamePatchesProviderByNameResponses, GetV0CityByCityNamePatchesProvidersData, GetV0CityByCityNamePatchesProvidersError, GetV0CityByCityNamePatchesProvidersErrors, GetV0CityByCityNamePatchesProvidersResponse, GetV0CityByCityNamePatchesProvidersResponses, GetV0CityByCityNamePatchesRigByNameData, GetV0CityByCityNamePatchesRigByNameError, GetV0CityByCityNamePatchesRigByNameErrors, GetV0CityByCityNamePatchesRigByNameResponse, GetV0CityByCityNamePatchesRigByNameResponses, GetV0CityByCityNamePatchesRigsData, GetV0CityByCityNamePatchesRigsError, GetV0CityByCityNamePatchesRigsErrors, GetV0CityByCityNamePatchesRigsResponse, GetV0CityByCityNamePatchesRigsResponses, GetV0CityByCityNamePendingData, GetV0CityByCityNamePendingError, GetV0CityByCityNamePendingErrors, GetV0CityByCityNamePendingResponse, GetV0CityByCityNamePendingResponses, GetV0CityByCityNameProviderByNameData, GetV0CityByCityNameProviderByNameError, GetV0CityByCityNameProviderByNameErrors, GetV0CityByCityNameProviderByNameResponse, GetV0CityByCityNameProviderByNameResponses, GetV0CityByCityNameProviderReadinessData, GetV0CityByCityNameProviderReadinessError, GetV0CityByCityNameProviderReadinessErrors, GetV0CityByCityNameProviderReadinessResponse, GetV0CityByCityNameProviderReadinessResponses, GetV0CityByCityNameProvidersData, GetV0CityByCityNameProvidersError, GetV0CityByCityNameProvidersErrors, GetV0CityByCityNameProvidersPublicData, GetV0CityByCityNameProvidersPublicError, GetV0CityByCityNameProvidersPublicErrors, GetV0CityByCityNameProvidersPublicResponse, GetV0CityByCityNameProvidersPublicResponses, GetV0CityByCityNameProvidersResponse, GetV0CityByCityNameProvidersResponses, GetV0CityByCityNameReadinessData, GetV0CityByCityNameReadinessError, GetV0CityByCityNameReadinessErrors, GetV0CityByCityNameReadinessResponse, GetV0CityByCityNameReadinessResponses, GetV0CityByCityNameResponse, GetV0CityByCityNameResponses, GetV0CityByCityNameRigByNameData, GetV0CityByCityNameRigByNameError, GetV0CityByCityNameRigByNameErrors, GetV0CityByCityNameRigByNameResponse, GetV0CityByCityNameRigByNameResponses, GetV0CityByCityNameRigsData, GetV0CityByCityNameRigsError, GetV0CityByCityNameRigsErrors, GetV0CityByCityNameRigsResponse, GetV0CityByCityNameRigsResponses, GetV0CityByCityNameRunsByRunIdData, GetV0CityByCityNameRunsByRunIdError, GetV0CityByCityNameRunsByRunIdErrors, GetV0CityByCityNameRunsByRunIdResponse, GetV0CityByCityNameRunsByRunIdResponses, GetV0CityByCityNameRunsByRunIdStepsData, GetV0CityByCityNameRunsByRunIdStepsError, GetV0CityByCityNameRunsByRunIdStepsErrors, GetV0CityByCityNameRunsByRunIdStepsResponse, GetV0CityByCityNameRunsByRunIdStepsResponses, GetV0CityByCityNameRunsCensusData, GetV0CityByCityNameRunsCensusError, GetV0CityByCityNameRunsCensusErrors, GetV0CityByCityNameRunsCensusResponse, GetV0CityByCityNameRunsCensusResponses, GetV0CityByCityNameRunsData, GetV0CityByCityNameRunsError, GetV0CityByCityNameRunsErrors, GetV0CityByCityNameRunsResponse, GetV0CityByCityNameRunsResponses, GetV0CityByCityNameServiceByNameData, GetV0CityByCityNameServiceByNameError, GetV0CityByCityNameServiceByNameErrors, GetV0CityByCityNameServiceByNameResponse, GetV0CityByCityNameServiceByNameResponses, GetV0CityByCityNameServicesData, GetV0CityByCityNameServicesError, GetV0CityByCityNameServicesErrors, GetV0CityByCityNameServicesResponse, GetV0CityByCityNameServicesResponses, GetV0CityByCityNameSessionByIdAgentsByAgentIdData, GetV0CityByCityNameSessionByIdAgentsByAgentIdError, GetV0CityByCityNameSessionByIdAgentsByAgentIdErrors, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse, GetV0CityByCityNameSessionByIdAgentsByAgentIdResponses, GetV0CityByCityNameSessionByIdAgentsData, GetV0CityByCityNameSessionByIdAgentsError, GetV0CityByCityNameSessionByIdAgentsErrors, GetV0CityByCityNameSessionByIdAgentsResponse, GetV0CityByCityNameSessionByIdAgentsResponses, GetV0CityByCityNameSessionByIdData, GetV0CityByCityNameSessionByIdError, GetV0CityByCityNameSessionByIdErrors, GetV0CityByCityNameSessionByIdPendingData, GetV0CityByCityNameSessionByIdPendingError, GetV0CityByCityNameSessionByIdPendingErrors, GetV0CityByCityNameSessionByIdPendingResponse, GetV0CityByCityNameSessionByIdPendingResponses, GetV0CityByCityNameSessionByIdResponse, GetV0CityByCityNameSessionByIdResponses, GetV0CityByCityNameSessionByIdTranscriptData, GetV0CityByCityNameSessionByIdTranscriptError, GetV0CityByCityNameSessionByIdTranscriptErrors, GetV0CityByCityNameSessionByIdTranscriptResponse, GetV0CityByCityNameSessionByIdTranscriptResponses, GetV0CityByCityNameSessionsData, GetV0CityByCityNameSessionsError, GetV0CityByCityNameSessionsErrors, GetV0CityByCityNameSessionsResponse, GetV0CityByCityNameSessionsResponses, GetV0CityByCityNameStatusData, GetV0CityByCityNameStatusError, GetV0CityByCityNameStatusErrors, GetV0CityByCityNameStatusResponse, GetV0CityByCityNameStatusResponses, GetV0CityByCityNameUsageData, GetV0CityByCityNameUsageError, GetV0CityByCityNameUsageErrors, GetV0CityByCityNameUsageResponse, GetV0CityByCityNameUsageResponses, GetV0CityByCityNameWaitByIdData, GetV0CityByCityNameWaitByIdError, GetV0CityByCityNameWaitByIdErrors, GetV0CityByCityNameWaitByIdResponse, GetV0CityByCityNameWaitByIdResponses, GetV0CityByCityNameWaitsData, GetV0CityByCityNameWaitsError, GetV0CityByCityNameWaitsErrors, GetV0CityByCityNameWaitsResponse, GetV0CityByCityNameWaitsResponses, GetV0CityByCityNameWorkflowByWorkflowIdData, GetV0CityByCityNameWorkflowByWorkflowIdError, GetV0CityByCityNameWorkflowByWorkflowIdErrors, GetV0CityByCityNameWorkflowByWorkflowIdResponse, GetV0CityByCityNameWorkflowByWorkflowIdResponses, GetV0EventsData, GetV0EventsError, GetV0EventsErrors, GetV0EventsResponse, GetV0EventsResponses, GetV0ProviderReadinessData, GetV0ProviderReadinessError, GetV0ProviderReadinessErrors, GetV0ProviderReadinessResponse, GetV0ProviderReadinessResponses, GetV0ReadinessData, GetV0ReadinessError, GetV0ReadinessErrors, GetV0ReadinessResponse, GetV0ReadinessResponses, GitStatus, GroupCreatedEventPayload, GroupRouteDecision, HealthOutputBody, HeartbeatEvent, InboundEventPayload, InboundResult, ListBodyAgentPatch, ListBodyAgentResponse, ListBodyBead, ListBodyCityPendingEntry, ListBodyConversationTranscriptRecord, ListBodyExtmsgAdapterInfo, ListBodyProviderPatch, ListBodyProviderResponse, ListBodyRigPatch, ListBodyRigResponse, ListBodySessionBindingRecord, ListBodySessionResponse, ListBodyStatus, ListBodyWireEvent, LogicalNode, MailCountOutputBody, MailEventPayload, MailListBody, MailReplyInputBody, MailSendInputBody, MaintenanceRunBody, MaintenanceStatusBody, MaintenanceTriggerBody, Message, MoleculeResolvedPayload, MonitorFeedItemResponse, NoPayload, OkResponseBody, OkWithIdResponseBody, OptionChoiceDto, OrderCheckListBody, OrderCheckResponse, OrderGateTimeoutFailOpenPayload, OrderHistoryDetailResponse, OrderHistoryEntry, OrderHistoryListBody, OrderListBody, OrderResponse, OrderRunInputBody, OrderRunOutputBody, OrdersFeedBody, OutboundChannelMismatchPayload, OutboundEventPayload, OutboundResult, OutputTurn, PackAddedOutputBody, PackAddInputBody, PackListBody, PackRemovedOutputBody, PackResponse, PaginationInfo, PatchDeletedResponseBody, PatchOkResponseBody, PatchV0CityByCityNameAgentByBaseData, PatchV0CityByCityNameAgentByBaseError, PatchV0CityByCityNameAgentByBaseErrors, PatchV0CityByCityNameAgentByBaseResponse, PatchV0CityByCityNameAgentByBaseResponses, PatchV0CityByCityNameAgentByDirByBaseData, PatchV0CityByCityNameAgentByDirByBaseError, PatchV0CityByCityNameAgentByDirByBaseErrors, PatchV0CityByCityNameAgentByDirByBaseResponse, PatchV0CityByCityNameAgentByDirByBaseResponses, PatchV0CityByCityNameBeadByIdData, PatchV0CityByCityNameBeadByIdError, PatchV0CityByCityNameBeadByIdErrors, PatchV0CityByCityNameBeadByIdResponse, PatchV0CityByCityNameBeadByIdResponses, PatchV0CityByCityNameData, PatchV0CityByCityNameError, PatchV0CityByCityNameErrors, PatchV0CityByCityNameProviderByNameData, PatchV0CityByCityNameProviderByNameError, PatchV0CityByCityNameProviderByNameErrors, PatchV0CityByCityNameProviderByNameResponse, PatchV0CityByCityNameProviderByNameResponses, PatchV0CityByCityNameResponse, PatchV0CityByCityNameResponses, PatchV0CityByCityNameRigByNameData, PatchV0CityByCityNameRigByNameError, PatchV0CityByCityNameRigByNameErrors, PatchV0CityByCityNameRigByNameResponse, PatchV0CityByCityNameRigByNameResponses, PatchV0CityByCityNameSessionByIdData, PatchV0CityByCityNameSessionByIdError, PatchV0CityByCityNameSessionByIdErrors, PatchV0CityByCityNameSessionByIdResponse, PatchV0CityByCityNameSessionByIdResponses, PendingInteraction, PoolOverride, PostgresCredentialResolvedPayload, PostV0CityByCityNameAgentByBaseByActionData, PostV0CityByCityNameAgentByBaseByActionError, PostV0CityByCityNameAgentByBaseByActionErrors, PostV0CityByCityNameAgentByBaseByActionResponse, PostV0CityByCityNameAgentByBaseByActionResponses, PostV0CityByCityNameAgentByDirByBaseByActionData, PostV0CityByCityNameAgentByDirByBaseByActionError, PostV0CityByCityNameAgentByDirByBaseByActionErrors, PostV0CityByCityNameAgentByDirByBaseByActionResponse, PostV0CityByCityNameAgentByDirByBaseByActionResponses, PostV0CityByCityNameBeadByIdAssignData, PostV0CityByCityNameBeadByIdAssignError, PostV0CityByCityNameBeadByIdAssignErrors, PostV0CityByCityNameBeadByIdAssignResponse, PostV0CityByCityNameBeadByIdAssignResponses, PostV0CityByCityNameBeadByIdCloseData, PostV0CityByCityNameBeadByIdCloseError, PostV0CityByCityNameBeadByIdCloseErrors, PostV0CityByCityNameBeadByIdCloseResponse, PostV0CityByCityNameBeadByIdCloseResponses, PostV0CityByCityNameBeadByIdReopenData, PostV0CityByCityNameBeadByIdReopenError, PostV0CityByCityNameBeadByIdReopenErrors, PostV0CityByCityNameBeadByIdReopenResponse, PostV0CityByCityNameBeadByIdReopenResponses, PostV0CityByCityNameBeadByIdUpdateData, PostV0CityByCityNameBeadByIdUpdateError, PostV0CityByCityNameBeadByIdUpdateErrors, PostV0CityByCityNameBeadByIdUpdateResponse, PostV0CityByCityNameBeadByIdUpdateResponses, PostV0CityByCityNameConvoyByIdAddData, PostV0CityByCityNameConvoyByIdAddError, PostV0CityByCityNameConvoyByIdAddErrors, PostV0CityByCityNameConvoyByIdAddResponse, PostV0CityByCityNameConvoyByIdAddResponses, PostV0CityByCityNameConvoyByIdCloseData, PostV0CityByCityNameConvoyByIdCloseError, PostV0CityByCityNameConvoyByIdCloseErrors, PostV0CityByCityNameConvoyByIdCloseResponse, PostV0CityByCityNameConvoyByIdCloseResponses, PostV0CityByCityNameConvoyByIdRemoveData, PostV0CityByCityNameConvoyByIdRemoveError, PostV0CityByCityNameConvoyByIdRemoveErrors, PostV0CityByCityNameConvoyByIdRemoveResponse, PostV0CityByCityNameConvoyByIdRemoveResponses, PostV0CityByCityNameExtmsgBindData, PostV0CityByCityNameExtmsgBindError, PostV0CityByCityNameExtmsgBindErrors, PostV0CityByCityNameExtmsgBindResponse, PostV0CityByCityNameExtmsgBindResponses, PostV0CityByCityNameExtmsgInboundData, PostV0CityByCityNameExtmsgInboundError, PostV0CityByCityNameExtmsgInboundErrors, PostV0CityByCityNameExtmsgInboundResponse, PostV0CityByCityNameExtmsgInboundResponses, PostV0CityByCityNameExtmsgOutboundData, PostV0CityByCityNameExtmsgOutboundError, PostV0CityByCityNameExtmsgOutboundErrors, PostV0CityByCityNameExtmsgOutboundResponse, PostV0CityByCityNameExtmsgOutboundResponses, PostV0CityByCityNameExtmsgParticipantsData, PostV0CityByCityNameExtmsgParticipantsError, PostV0CityByCityNameExtmsgParticipantsErrors, PostV0CityByCityNameExtmsgParticipantsResponse, PostV0CityByCityNameExtmsgParticipantsResponses, PostV0CityByCityNameExtmsgTranscriptAckData, PostV0CityByCityNameExtmsgTranscriptAckError, PostV0CityByCityNameExtmsgTranscriptAckErrors, PostV0CityByCityNameExtmsgTranscriptAckResponse, PostV0CityByCityNameExtmsgTranscriptAckResponses, PostV0CityByCityNameExtmsgUnbindData, PostV0CityByCityNameExtmsgUnbindError, PostV0CityByCityNameExtmsgUnbindErrors, PostV0CityByCityNameExtmsgUnbindResponse, PostV0CityByCityNameExtmsgUnbindResponses, PostV0CityByCityNameFormulasByNamePreviewData, PostV0CityByCityNameFormulasByNamePreviewError, PostV0CityByCityNameFormulasByNamePreviewErrors, PostV0CityByCityNameFormulasByNamePreviewResponse, PostV0CityByCityNameFormulasByNamePreviewResponses, PostV0CityByCityNameFormulasByNameValidateData, PostV0CityByCityNameFormulasByNameValidateError, PostV0CityByCityNameFormulasByNameValidateErrors, PostV0CityByCityNameFormulasByNameValidateResponse, PostV0CityByCityNameFormulasByNameValidateResponses, PostV0CityByCityNameMailByIdArchiveData, PostV0CityByCityNameMailByIdArchiveError, PostV0CityByCityNameMailByIdArchiveErrors, PostV0CityByCityNameMailByIdArchiveResponse, PostV0CityByCityNameMailByIdArchiveResponses, PostV0CityByCityNameMailByIdMarkUnreadData, PostV0CityByCityNameMailByIdMarkUnreadError, PostV0CityByCityNameMailByIdMarkUnreadErrors, PostV0CityByCityNameMailByIdMarkUnreadResponse, PostV0CityByCityNameMailByIdMarkUnreadResponses, PostV0CityByCityNameMailByIdReadData, PostV0CityByCityNameMailByIdReadError, PostV0CityByCityNameMailByIdReadErrors, PostV0CityByCityNameMailByIdReadResponse, PostV0CityByCityNameMailByIdReadResponses, PostV0CityByCityNameOrderByNameDisableData, PostV0CityByCityNameOrderByNameDisableError, PostV0CityByCityNameOrderByNameDisableErrors, PostV0CityByCityNameOrderByNameDisableResponse, PostV0CityByCityNameOrderByNameDisableResponses, PostV0CityByCityNameOrderByNameEnableData, PostV0CityByCityNameOrderByNameEnableError, PostV0CityByCityNameOrderByNameEnableErrors, PostV0CityByCityNameOrderByNameEnableResponse, PostV0CityByCityNameOrderByNameEnableResponses, PostV0CityByCityNameOrderByNameRunData, PostV0CityByCityNameOrderByNameRunError, PostV0CityByCityNameOrderByNameRunErrors, PostV0CityByCityNameOrderByNameRunResponse, PostV0CityByCityNameOrderByNameRunResponses, PostV0CityByCityNameRigByNameByActionData, PostV0CityByCityNameRigByNameByActionError, PostV0CityByCityNameRigByNameByActionErrors, PostV0CityByCityNameRigByNameByActionResponse, PostV0CityByCityNameRigByNameByActionResponses, PostV0CityByCityNameRunsByRunIdCancelData, PostV0CityByCityNameRunsByRunIdCancelError, PostV0CityByCityNameRunsByRunIdCancelErrors, PostV0CityByCityNameRunsByRunIdCancelResponse, PostV0CityByCityNameRunsByRunIdCancelResponses, PostV0CityByCityNameServiceByNameRestartData, PostV0CityByCityNameServiceByNameRestartError, PostV0CityByCityNameServiceByNameRestartErrors, PostV0CityByCityNameServiceByNameRestartResponse, PostV0CityByCityNameServiceByNameRestartResponses, PostV0CityByCityNameSessionByIdCloseData, PostV0CityByCityNameSessionByIdCloseError, PostV0CityByCityNameSessionByIdCloseErrors, PostV0CityByCityNameSessionByIdCloseResponse, PostV0CityByCityNameSessionByIdCloseResponses, PostV0CityByCityNameSessionByIdKillData, PostV0CityByCityNameSessionByIdKillError, PostV0CityByCityNameSessionByIdKillErrors, PostV0CityByCityNameSessionByIdKillResponse, PostV0CityByCityNameSessionByIdKillResponses, PostV0CityByCityNameSessionByIdPermissionModeData, PostV0CityByCityNameSessionByIdPermissionModeError, PostV0CityByCityNameSessionByIdPermissionModeErrors, PostV0CityByCityNameSessionByIdPermissionModeResponse, PostV0CityByCityNameSessionByIdPermissionModeResponses, PostV0CityByCityNameSessionByIdRenameData, PostV0CityByCityNameSessionByIdRenameError, PostV0CityByCityNameSessionByIdRenameErrors, PostV0CityByCityNameSessionByIdRenameResponse, PostV0CityByCityNameSessionByIdRenameResponses, PostV0CityByCityNameSessionByIdStopData, PostV0CityByCityNameSessionByIdStopError, PostV0CityByCityNameSessionByIdStopErrors, PostV0CityByCityNameSessionByIdStopResponse, PostV0CityByCityNameSessionByIdStopResponses, PostV0CityByCityNameSessionByIdSuspendData, PostV0CityByCityNameSessionByIdSuspendError, PostV0CityByCityNameSessionByIdSuspendErrors, PostV0CityByCityNameSessionByIdSuspendResponse, PostV0CityByCityNameSessionByIdSuspendResponses, PostV0CityByCityNameSessionByIdWakeData, PostV0CityByCityNameSessionByIdWakeError, PostV0CityByCityNameSessionByIdWakeErrors, PostV0CityByCityNameSessionByIdWakeResponse, PostV0CityByCityNameSessionByIdWakeResponses, PostV0CityByCityNameSlingData, PostV0CityByCityNameSlingError, PostV0CityByCityNameSlingErrors, PostV0CityByCityNameSlingResponse, PostV0CityByCityNameSlingResponses, PostV0CityByCityNameUnregisterData, PostV0CityByCityNameUnregisterError, PostV0CityByCityNameUnregisterErrors, PostV0CityByCityNameUnregisterResponse, PostV0CityByCityNameUnregisterResponses, PostV0CityData, PostV0CityError, PostV0CityErrors, PostV0CityResponse, PostV0CityResponses, ProjectIdentityStampedPayload, ProviderCreatedOutputBody, ProviderCreateInputBody, ProviderOptionDto, ProviderPatch, ProviderPatchSetInputBody, ProviderPublicListBody, ProviderPublicResponse, ProviderReadiness, ProviderReadinessResponse, ProviderResponse, ProviderSpecJson, ProviderUpdateInputBody, ProxyReapedPayload, PublishReceipt, PutV0CityByCityNameFormulasByNameData, PutV0CityByCityNameFormulasByNameError, PutV0CityByCityNameFormulasByNameErrors, PutV0CityByCityNameFormulasByNameResponse, PutV0CityByCityNameFormulasByNameResponses, PutV0CityByCityNamePatchesAgentsData, PutV0CityByCityNamePatchesAgentsError, PutV0CityByCityNamePatchesAgentsErrors, PutV0CityByCityNamePatchesAgentsResponse, PutV0CityByCityNamePatchesAgentsResponses, PutV0CityByCityNamePatchesProvidersData, PutV0CityByCityNamePatchesProvidersError, PutV0CityByCityNamePatchesProvidersErrors, PutV0CityByCityNamePatchesProvidersResponse, PutV0CityByCityNamePatchesProvidersResponses, PutV0CityByCityNamePatchesRigsData, PutV0CityByCityNamePatchesRigsError, PutV0CityByCityNamePatchesRigsErrors, PutV0CityByCityNamePatchesRigsResponse, PutV0CityByCityNamePatchesRigsResponses, QuotaObservedPayload, QuotaPollFailedPayload, ReadinessItem, ReadinessResponse, Record, RegisterExtmsgAdapterData, RegisterExtmsgAdapterError, RegisterExtmsgAdapterErrors, RegisterExtmsgAdapterResponse, RegisterExtmsgAdapterResponses, ReplyMailData, ReplyMailError, ReplyMailErrors, ReplyMailResponse, ReplyMailResponses, RequestFailedPayload, RespondSessionData, RespondSessionError, RespondSessionErrors, RespondSessionResponse, RespondSessionResponses, RigActionBody, RigCreateBody, RigCreateResponseBody, RigCreateSucceededPayload, RigPatch, RigPatchSetInputBody, RigProvisionProgressPayload, RigResponse, RigUpdateInputBody, RotatedPayload, RotateEventsData, RotateEventsError, RotateEventsErrors, RotateEventsResponse, RotateEventsResponses, Run, RunCancelOutputBody, RunLastError, RunRef, RunsCensusOutputBody, RunScope, RunsListOutputBody, RunStatus, RunStatusCounts, RunStep, RunStepsOutputBody, RunStepStatus, ScopeGroup, SendMailData, SendMailError, SendMailErrors, SendMailResponse, SendMailResponses, SendSessionMessageData, SendSessionMessageError, SendSessionMessageErrors, SendSessionMessageResponse, SendSessionMessageResponses, ServiceRestartOutputBody, SessionActivityEvent, SessionAgentGetResponse, SessionAgentListResponse, SessionBindingRecord, SessionCreateBody, SessionCreateSucceededPayload, SessionDrainAckedWithAssignedWorkPayload, SessionInfo, SessionLifecyclePayload, SessionMessageInputBody, SessionMessageSucceededPayload, SessionPatchBody, SessionPendingClearedEvent, SessionPendingResponse, SessionPermissionModeBody, SessionRawMessageFrame, SessionRenameInputBody, SessionResetStalledPayload, SessionRespondInputBody, SessionRespondOutputBody, SessionResponse, SessionStrandedPayload, SessionStreamCommonEvent, SessionStreamMessageEvent, SessionStreamRawMessageEvent, SessionStreamStructuredMessageEvent, SessionStructuredArgument, SessionStructuredBlock, SessionStructuredBlockImage, SessionStructuredBlockInteraction, SessionStructuredBlockText, SessionStructuredBlockThinking, SessionStructuredBlockToolResult, SessionStructuredBlockToolUse, SessionStructuredBlockUnknown, SessionStructuredContinuity, SessionStructuredCursor, SessionStructuredDiagnostic, SessionStructuredGeneration, SessionStructuredHistory, SessionStructuredIdeSelection, SessionStructuredInteraction, SessionStructuredMessage, SessionStructuredMessageAssistant, SessionStructuredMessageSystem, SessionStructuredMessageTool, SessionStructuredMessageUnknown, SessionStructuredMessageUser, SessionStructuredPatchHunk, SessionStructuredPlanStep, SessionStructuredQuestion, SessionStructuredQuestionOption, SessionStructuredSearchResultItem, SessionStructuredSystemEvent, SessionStructuredTailState, SessionStructuredTodoItem, SessionStructuredToolError, SessionStructuredToolInput, SessionStructuredToolInputArguments, SessionStructuredToolInputCode, SessionStructuredToolInputCommand, SessionStructuredToolInputFetch, SessionStructuredToolInputFile, SessionStructuredToolInputGlob, SessionStructuredToolInputPatch, SessionStructuredToolInputPlan, SessionStructuredToolInputQuestion, SessionStructuredToolInputSearch, SessionStructuredToolInputStdin, SessionStructuredToolInputTask, SessionStructuredToolInputText, SessionStructuredToolInputTodo, SessionStructuredToolInputUnknown, SessionStructuredToolInputWrite, SessionStructuredToolResult, SessionStructuredToolResultBash, SessionStructuredToolResultEdit, SessionStructuredToolResultFetch, SessionStructuredToolResultGlob, SessionStructuredToolResultGrep, SessionStructuredToolResultPlan, SessionStructuredToolResultPython, SessionStructuredToolResultQuestion, SessionStructuredToolResultRead, SessionStructuredToolResultSearch, SessionStructuredToolResultStdin, SessionStructuredToolResultTask, SessionStructuredToolResultText, SessionStructuredToolResultTodo, SessionStructuredToolResultUnknown, SessionStructuredToolResultWrite, SessionStructuredUploadedFile, SessionStructuredUsage, SessionStructuredUserPrompt, SessionSubmitInputBody, SessionSubmitSucceededPayload, SessionTranscriptConversationResponse, SessionTranscriptGetResponse, SessionTranscriptRawResponse, SessionTranscriptStructuredResponse, SessionUnknownStatePayload, SlingInputBody, SlingResponse, Status, StatusAgentCounts, StatusAgentDetail, StatusBody, StatusConditionalWrites, StatusConditionalWriteStoreVerdict, StatusMailCounts, StatusNamedSessionDetail, StatusRigCounts, StatusRigDetail, StatusRolloutNotice, StatusSessionCountsDetail, StatusStoreHealth, StatusWorkCounts, StoreDegradedPayload, StoreDiskCriticalPayload, StoreDiskWarnPayload, StoreMaintenanceDonePayload, StoreMaintenanceFailedPayload, StoreProbeFailedPayload, StoreRecoveredPayload, StreamAgentOutputData, StreamAgentOutputError, StreamAgentOutputErrors, StreamAgentOutputQualifiedData, StreamAgentOutputQualifiedError, StreamAgentOutputQualifiedErrors, StreamAgentOutputQualifiedResponse, StreamAgentOutputQualifiedResponses, StreamAgentOutputResponse, StreamAgentOutputResponses, StreamEventsData, StreamEventsError, StreamEventsErrors, StreamEventsResponse, StreamEventsResponses, StreamSessionData, StreamSessionError, StreamSessionErrors, StreamSessionResponse, StreamSessionResponses, StreamSupervisorEventsData, StreamSupervisorEventsError, StreamSupervisorEventsErrors, StreamSupervisorEventsResponse, StreamSupervisorEventsResponses, SubmissionCapabilities, SubmitIntent, SubmitSessionData, SubmitSessionError, SubmitSessionErrors, SubmitSessionResponse, SubmitSessionResponses, SupervisorCitiesOutputBody, SupervisorEventListOutputBody, SupervisorFsPressureSkippedTickPayload, SupervisorHealthOutputBody, SupervisorRequestPayload, SupervisorShutdownPayload, SupervisorStartedPayload, SupervisorStartup, TaggedEventStreamEnvelope, TranscriptMessageKind, TranscriptProvenance, TriggerMaintenanceDoltGcData, TriggerMaintenanceDoltGcError, TriggerMaintenanceDoltGcErrors, TriggerMaintenanceDoltGcResponse, TriggerMaintenanceDoltGcResponses, TypedEventStreamEnvelope, TypedEventStreamEnvelopeBeadClaimRejected, TypedEventStreamEnvelopeBeadClosed, TypedEventStreamEnvelopeBeadCreated, TypedEventStreamEnvelopeBeadDeadAssigneeReopened, TypedEventStreamEnvelopeBeadDeleted, TypedEventStreamEnvelopeBeadsConditionalWritesDegraded, TypedEventStreamEnvelopeBeadUpdated, TypedEventStreamEnvelopeBeadWorktreeReaped, TypedEventStreamEnvelopeBeadWorktreeReapSkipped, TypedEventStreamEnvelopeBreakerStateChanged, TypedEventStreamEnvelopeCityCreated, TypedEventStreamEnvelopeCityResumed, TypedEventStreamEnvelopeCitySuspended, TypedEventStreamEnvelopeCityUnregisterRequested, TypedEventStreamEnvelopeControllerStarted, TypedEventStreamEnvelopeControllerStopped, TypedEventStreamEnvelopeControllerTickCompleted, TypedEventStreamEnvelopeConvoyClosed, TypedEventStreamEnvelopeConvoyCreated, TypedEventStreamEnvelopeCustom, TypedEventStreamEnvelopeDoctorAlert, TypedEventStreamEnvelopeEmergencyAcked, TypedEventStreamEnvelopeEmergencySignaled, TypedEventStreamEnvelopeEventsRotated, TypedEventStreamEnvelopeExecutionStepDefined, TypedEventStreamEnvelopeExecutionWorkAssociated, TypedEventStreamEnvelopeExtmsgAdapterAdded, TypedEventStreamEnvelopeExtmsgAdapterRemoved, TypedEventStreamEnvelopeExtmsgBound, TypedEventStreamEnvelopeExtmsgGroupCreated, TypedEventStreamEnvelopeExtmsgInbound, TypedEventStreamEnvelopeExtmsgOutbound, TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch, TypedEventStreamEnvelopeExtmsgUnbound, TypedEventStreamEnvelopeGcStoreDiskCritical, TypedEventStreamEnvelopeGcStoreDiskWarn, TypedEventStreamEnvelopeGcStoreMaintenanceDone, TypedEventStreamEnvelopeGcStoreMaintenanceFailed, TypedEventStreamEnvelopeMailArchived, TypedEventStreamEnvelopeMailDeleted, TypedEventStreamEnvelopeMailMarkedRead, TypedEventStreamEnvelopeMailMarkedUnread, TypedEventStreamEnvelopeMailRead, TypedEventStreamEnvelopeMailReplied, TypedEventStreamEnvelopeMailSent, TypedEventStreamEnvelopeMoleculeResolved, TypedEventStreamEnvelopeOrderCompleted, TypedEventStreamEnvelopeOrderFailed, TypedEventStreamEnvelopeOrderFired, TypedEventStreamEnvelopeOrderGateTimeoutFailOpen, TypedEventStreamEnvelopePgCredentialResolved, TypedEventStreamEnvelopeProjectIdentityStamped, TypedEventStreamEnvelopeProviderQuotaObserved, TypedEventStreamEnvelopeProviderQuotaPollFailed, TypedEventStreamEnvelopeProviderSwapped, TypedEventStreamEnvelopeProxyReaped, TypedEventStreamEnvelopeRequestFailed, TypedEventStreamEnvelopeRequestResultCityCreate, TypedEventStreamEnvelopeRequestResultCityUnregister, TypedEventStreamEnvelopeRequestResultRigCreate, TypedEventStreamEnvelopeRequestResultSessionCreate, TypedEventStreamEnvelopeRequestResultSessionMessage, TypedEventStreamEnvelopeRequestResultSessionSubmit, TypedEventStreamEnvelopeRigProvisionProgress, TypedEventStreamEnvelopeSessionColdStartTimeout, TypedEventStreamEnvelopeSessionCrashed, TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork, TypedEventStreamEnvelopeSessionDraining, TypedEventStreamEnvelopeSessionIdleKilled, TypedEventStreamEnvelopeSessionMaxAgeKilled, TypedEventStreamEnvelopeSessionQuarantined, TypedEventStreamEnvelopeSessionResetStalled, TypedEventStreamEnvelopeSessionStopped, TypedEventStreamEnvelopeSessionStranded, TypedEventStreamEnvelopeSessionSuspended, TypedEventStreamEnvelopeSessionUndrained, TypedEventStreamEnvelopeSessionUnknownState, TypedEventStreamEnvelopeSessionUpdated, TypedEventStreamEnvelopeSessionWoke, TypedEventStreamEnvelopeSessionWorkQueryFailed, TypedEventStreamEnvelopeStoreDegraded, TypedEventStreamEnvelopeStoreProbeFailed, TypedEventStreamEnvelopeStoreRecovered, TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick, TypedEventStreamEnvelopeSupervisorRequest, TypedEventStreamEnvelopeSupervisorShutdownRequested, TypedEventStreamEnvelopeSupervisorStarted, TypedEventStreamEnvelopeWebhookReceived, TypedEventStreamEnvelopeWebhookRejected, TypedEventStreamEnvelopeWorkerOperation, TypedTaggedEventStreamEnvelope, TypedTaggedEventStreamEnvelopeBeadClaimRejected, TypedTaggedEventStreamEnvelopeBeadClosed, TypedTaggedEventStreamEnvelopeBeadCreated, TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened, TypedTaggedEventStreamEnvelopeBeadDeleted, TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded, TypedTaggedEventStreamEnvelopeBeadUpdated, TypedTaggedEventStreamEnvelopeBeadWorktreeReaped, TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped, TypedTaggedEventStreamEnvelopeBreakerStateChanged, TypedTaggedEventStreamEnvelopeCityCreated, TypedTaggedEventStreamEnvelopeCityResumed, TypedTaggedEventStreamEnvelopeCitySuspended, TypedTaggedEventStreamEnvelopeCityUnregisterRequested, TypedTaggedEventStreamEnvelopeControllerStarted, TypedTaggedEventStreamEnvelopeControllerStopped, TypedTaggedEventStreamEnvelopeControllerTickCompleted, TypedTaggedEventStreamEnvelopeConvoyClosed, TypedTaggedEventStreamEnvelopeConvoyCreated, TypedTaggedEventStreamEnvelopeCustom, TypedTaggedEventStreamEnvelopeDoctorAlert, TypedTaggedEventStreamEnvelopeEmergencyAcked, TypedTaggedEventStreamEnvelopeEmergencySignaled, TypedTaggedEventStreamEnvelopeEventsRotated, TypedTaggedEventStreamEnvelopeExecutionStepDefined, TypedTaggedEventStreamEnvelopeExecutionWorkAssociated, TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded, TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved, TypedTaggedEventStreamEnvelopeExtmsgBound, TypedTaggedEventStreamEnvelopeExtmsgGroupCreated, TypedTaggedEventStreamEnvelopeExtmsgInbound, TypedTaggedEventStreamEnvelopeExtmsgOutbound, TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch, TypedTaggedEventStreamEnvelopeExtmsgUnbound, TypedTaggedEventStreamEnvelopeGcStoreDiskCritical, TypedTaggedEventStreamEnvelopeGcStoreDiskWarn, TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone, TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed, TypedTaggedEventStreamEnvelopeMailArchived, TypedTaggedEventStreamEnvelopeMailDeleted, TypedTaggedEventStreamEnvelopeMailMarkedRead, TypedTaggedEventStreamEnvelopeMailMarkedUnread, TypedTaggedEventStreamEnvelopeMailRead, TypedTaggedEventStreamEnvelopeMailReplied, TypedTaggedEventStreamEnvelopeMailSent, TypedTaggedEventStreamEnvelopeMoleculeResolved, TypedTaggedEventStreamEnvelopeOrderCompleted, TypedTaggedEventStreamEnvelopeOrderFailed, TypedTaggedEventStreamEnvelopeOrderFired, TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen, TypedTaggedEventStreamEnvelopePgCredentialResolved, TypedTaggedEventStreamEnvelopeProjectIdentityStamped, TypedTaggedEventStreamEnvelopeProviderQuotaObserved, TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed, TypedTaggedEventStreamEnvelopeProviderSwapped, TypedTaggedEventStreamEnvelopeProxyReaped, TypedTaggedEventStreamEnvelopeRequestFailed, TypedTaggedEventStreamEnvelopeRequestResultCityCreate, TypedTaggedEventStreamEnvelopeRequestResultCityUnregister, TypedTaggedEventStreamEnvelopeRequestResultRigCreate, TypedTaggedEventStreamEnvelopeRequestResultSessionCreate, TypedTaggedEventStreamEnvelopeRequestResultSessionMessage, TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit, TypedTaggedEventStreamEnvelopeRigProvisionProgress, TypedTaggedEventStreamEnvelopeSessionColdStartTimeout, TypedTaggedEventStreamEnvelopeSessionCrashed, TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork, TypedTaggedEventStreamEnvelopeSessionDraining, TypedTaggedEventStreamEnvelopeSessionIdleKilled, TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled, TypedTaggedEventStreamEnvelopeSessionQuarantined, TypedTaggedEventStreamEnvelopeSessionResetStalled, TypedTaggedEventStreamEnvelopeSessionStopped, TypedTaggedEventStreamEnvelopeSessionStranded, TypedTaggedEventStreamEnvelopeSessionSuspended, TypedTaggedEventStreamEnvelopeSessionUndrained, TypedTaggedEventStreamEnvelopeSessionUnknownState, TypedTaggedEventStreamEnvelopeSessionUpdated, TypedTaggedEventStreamEnvelopeSessionWoke, TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed, TypedTaggedEventStreamEnvelopeStoreDegraded, TypedTaggedEventStreamEnvelopeStoreProbeFailed, TypedTaggedEventStreamEnvelopeStoreRecovered, TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick, TypedTaggedEventStreamEnvelopeSupervisorRequest, TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested, TypedTaggedEventStreamEnvelopeSupervisorStarted, TypedTaggedEventStreamEnvelopeWebhookReceived, TypedTaggedEventStreamEnvelopeWebhookRejected, TypedTaggedEventStreamEnvelopeWorkerOperation, UnboundEventPayload, UsageBody, UsageSessionRecent, UsageTotals, WaitListBody, WaitView, WebhookReceivedPayload, WebhookRejectedPayload, WorkerOperationEventPayload, WorkflowAttemptSummary, WorkflowBeadResponse, WorkflowDeleteResponse, WorkflowDepResponse, WorkflowEventProjection, WorkflowSnapshotResponse, WorkspaceResponse } from './types.gen.js';
diff --git a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/types.gen.ts b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/types.gen.ts
index f597d89686..dd8b0cebd5 100644
--- a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/types.gen.ts
+++ b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/types.gen.ts
@@ -60,6 +60,7 @@ export type AgentOutputResponse = {
export type AgentPatch = {
AppendFragments: Array | null;
Args: Array | null;
+ AssignedWorkDeferLimit: number | null;
Attach: boolean | null;
DefaultSlingFormula: string | null;
DependsOn: Array | null;
@@ -965,6 +966,7 @@ export type EventRotateResponse = {
export type EventStreamEnvelope = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload?: EventPayload;
run_id?: string;
@@ -5229,6 +5231,7 @@ export type SupervisorStartup = {
export type TaggedEventStreamEnvelope = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload?: EventPayload;
run_id?: string;
@@ -5303,6 +5306,10 @@ export type TypedEventStreamEnvelope = ({
} & TypedEventStreamEnvelopeEmergencySignaled) | ({
type: 'events.rotated';
} & TypedEventStreamEnvelopeEventsRotated) | ({
+ type: 'execution.step_defined';
+} & TypedEventStreamEnvelopeExecutionStepDefined) | ({
+ type: 'execution.work_associated';
+} & TypedEventStreamEnvelopeExecutionWorkAssociated) | ({
type: 'extmsg.adapter_added';
} & TypedEventStreamEnvelopeExtmsgAdapterAdded) | ({
type: 'extmsg.adapter_removed';
@@ -5439,6 +5446,7 @@ export type TypedEventStreamEnvelope = ({
*/
export type TypedEventStreamEnvelopeBeadClaimRejected = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadClaimRejectedPayload;
run_id?: string;
@@ -5456,6 +5464,7 @@ export type TypedEventStreamEnvelopeBeadClaimRejected = {
*/
export type TypedEventStreamEnvelopeBeadClosed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -5473,6 +5482,7 @@ export type TypedEventStreamEnvelopeBeadClosed = {
*/
export type TypedEventStreamEnvelopeBeadCreated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -5490,6 +5500,7 @@ export type TypedEventStreamEnvelopeBeadCreated = {
*/
export type TypedEventStreamEnvelopeBeadDeadAssigneeReopened = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadDeadAssigneeReopenedPayload;
run_id?: string;
@@ -5507,6 +5518,7 @@ export type TypedEventStreamEnvelopeBeadDeadAssigneeReopened = {
*/
export type TypedEventStreamEnvelopeBeadDeleted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -5524,6 +5536,7 @@ export type TypedEventStreamEnvelopeBeadDeleted = {
*/
export type TypedEventStreamEnvelopeBeadUpdated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -5541,6 +5554,7 @@ export type TypedEventStreamEnvelopeBeadUpdated = {
*/
export type TypedEventStreamEnvelopeBeadWorktreeReapSkipped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadWorktreeReapSkippedPayload;
run_id?: string;
@@ -5558,6 +5572,7 @@ export type TypedEventStreamEnvelopeBeadWorktreeReapSkipped = {
*/
export type TypedEventStreamEnvelopeBeadWorktreeReaped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadWorktreeReapedPayload;
run_id?: string;
@@ -5575,6 +5590,7 @@ export type TypedEventStreamEnvelopeBeadWorktreeReaped = {
*/
export type TypedEventStreamEnvelopeBeadsConditionalWritesDegraded = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ConditionalWritesDegradedPayload;
run_id?: string;
@@ -5592,6 +5608,7 @@ export type TypedEventStreamEnvelopeBeadsConditionalWritesDegraded = {
*/
export type TypedEventStreamEnvelopeBreakerStateChanged = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BreakerStateChangedPayload;
run_id?: string;
@@ -5609,6 +5626,7 @@ export type TypedEventStreamEnvelopeBreakerStateChanged = {
*/
export type TypedEventStreamEnvelopeCityCreated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityLifecyclePayload;
run_id?: string;
@@ -5626,6 +5644,7 @@ export type TypedEventStreamEnvelopeCityCreated = {
*/
export type TypedEventStreamEnvelopeCityResumed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5643,6 +5662,7 @@ export type TypedEventStreamEnvelopeCityResumed = {
*/
export type TypedEventStreamEnvelopeCitySuspended = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5660,6 +5680,7 @@ export type TypedEventStreamEnvelopeCitySuspended = {
*/
export type TypedEventStreamEnvelopeCityUnregisterRequested = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityLifecyclePayload;
run_id?: string;
@@ -5677,6 +5698,7 @@ export type TypedEventStreamEnvelopeCityUnregisterRequested = {
*/
export type TypedEventStreamEnvelopeControllerStarted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5694,6 +5716,7 @@ export type TypedEventStreamEnvelopeControllerStarted = {
*/
export type TypedEventStreamEnvelopeControllerStopped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5711,6 +5734,7 @@ export type TypedEventStreamEnvelopeControllerStopped = {
*/
export type TypedEventStreamEnvelopeControllerTickCompleted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ControllerTickCompletedPayload;
run_id?: string;
@@ -5728,6 +5752,7 @@ export type TypedEventStreamEnvelopeControllerTickCompleted = {
*/
export type TypedEventStreamEnvelopeConvoyClosed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5745,6 +5770,7 @@ export type TypedEventStreamEnvelopeConvoyClosed = {
*/
export type TypedEventStreamEnvelopeConvoyCreated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -5762,6 +5788,7 @@ export type TypedEventStreamEnvelopeConvoyCreated = {
*/
export type TypedEventStreamEnvelopeCustom = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: unknown;
run_id?: string;
@@ -5779,6 +5806,7 @@ export type TypedEventStreamEnvelopeCustom = {
*/
export type TypedEventStreamEnvelopeDoctorAlert = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: DoctorAlertPayload;
run_id?: string;
@@ -5796,6 +5824,7 @@ export type TypedEventStreamEnvelopeDoctorAlert = {
*/
export type TypedEventStreamEnvelopeEmergencyAcked = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: Record;
run_id?: string;
@@ -5813,6 +5842,7 @@ export type TypedEventStreamEnvelopeEmergencyAcked = {
*/
export type TypedEventStreamEnvelopeEmergencySignaled = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: Record;
run_id?: string;
@@ -5830,6 +5860,7 @@ export type TypedEventStreamEnvelopeEmergencySignaled = {
*/
export type TypedEventStreamEnvelopeEventsRotated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RotatedPayload;
run_id?: string;
@@ -5842,11 +5873,48 @@ export type TypedEventStreamEnvelopeEventsRotated = {
workflow?: WorkflowEventProjection;
};
+/**
+ * TypedEventStreamEnvelope execution.step_defined
+ */
+export type TypedEventStreamEnvelopeExecutionStepDefined = {
+ actor: string;
+ depends_on_step_ids?: Array;
+ message?: string;
+ payload: NoPayload;
+ run_id?: string;
+ seq: number;
+ session_id?: string;
+ step_id?: string;
+ subject?: string;
+ ts: string;
+ type: 'execution.step_defined';
+ workflow?: WorkflowEventProjection;
+};
+
+/**
+ * TypedEventStreamEnvelope execution.work_associated
+ */
+export type TypedEventStreamEnvelopeExecutionWorkAssociated = {
+ actor: string;
+ depends_on_step_ids?: Array;
+ message?: string;
+ payload: NoPayload;
+ run_id?: string;
+ seq: number;
+ session_id?: string;
+ step_id?: string;
+ subject?: string;
+ ts: string;
+ type: 'execution.work_associated';
+ workflow?: WorkflowEventProjection;
+};
+
/**
* TypedEventStreamEnvelope extmsg.adapter_added
*/
export type TypedEventStreamEnvelopeExtmsgAdapterAdded = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: AdapterEventPayload;
run_id?: string;
@@ -5864,6 +5932,7 @@ export type TypedEventStreamEnvelopeExtmsgAdapterAdded = {
*/
export type TypedEventStreamEnvelopeExtmsgAdapterRemoved = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: AdapterEventPayload;
run_id?: string;
@@ -5881,6 +5950,7 @@ export type TypedEventStreamEnvelopeExtmsgAdapterRemoved = {
*/
export type TypedEventStreamEnvelopeExtmsgBound = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BoundEventPayload;
run_id?: string;
@@ -5898,6 +5968,7 @@ export type TypedEventStreamEnvelopeExtmsgBound = {
*/
export type TypedEventStreamEnvelopeExtmsgGroupCreated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: GroupCreatedEventPayload;
run_id?: string;
@@ -5915,6 +5986,7 @@ export type TypedEventStreamEnvelopeExtmsgGroupCreated = {
*/
export type TypedEventStreamEnvelopeExtmsgInbound = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: InboundEventPayload;
run_id?: string;
@@ -5932,6 +6004,7 @@ export type TypedEventStreamEnvelopeExtmsgInbound = {
*/
export type TypedEventStreamEnvelopeExtmsgOutbound = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OutboundEventPayload;
run_id?: string;
@@ -5949,6 +6022,7 @@ export type TypedEventStreamEnvelopeExtmsgOutbound = {
*/
export type TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OutboundChannelMismatchPayload;
run_id?: string;
@@ -5966,6 +6040,7 @@ export type TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch = {
*/
export type TypedEventStreamEnvelopeExtmsgUnbound = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: UnboundEventPayload;
run_id?: string;
@@ -5983,6 +6058,7 @@ export type TypedEventStreamEnvelopeExtmsgUnbound = {
*/
export type TypedEventStreamEnvelopeGcStoreDiskCritical = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDiskCriticalPayload;
run_id?: string;
@@ -6000,6 +6076,7 @@ export type TypedEventStreamEnvelopeGcStoreDiskCritical = {
*/
export type TypedEventStreamEnvelopeGcStoreDiskWarn = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDiskWarnPayload;
run_id?: string;
@@ -6017,6 +6094,7 @@ export type TypedEventStreamEnvelopeGcStoreDiskWarn = {
*/
export type TypedEventStreamEnvelopeGcStoreMaintenanceDone = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreMaintenanceDonePayload;
run_id?: string;
@@ -6034,6 +6112,7 @@ export type TypedEventStreamEnvelopeGcStoreMaintenanceDone = {
*/
export type TypedEventStreamEnvelopeGcStoreMaintenanceFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreMaintenanceFailedPayload;
run_id?: string;
@@ -6051,6 +6130,7 @@ export type TypedEventStreamEnvelopeGcStoreMaintenanceFailed = {
*/
export type TypedEventStreamEnvelopeMailArchived = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6068,6 +6148,7 @@ export type TypedEventStreamEnvelopeMailArchived = {
*/
export type TypedEventStreamEnvelopeMailDeleted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6085,6 +6166,7 @@ export type TypedEventStreamEnvelopeMailDeleted = {
*/
export type TypedEventStreamEnvelopeMailMarkedRead = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6102,6 +6184,7 @@ export type TypedEventStreamEnvelopeMailMarkedRead = {
*/
export type TypedEventStreamEnvelopeMailMarkedUnread = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6119,6 +6202,7 @@ export type TypedEventStreamEnvelopeMailMarkedUnread = {
*/
export type TypedEventStreamEnvelopeMailRead = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6136,6 +6220,7 @@ export type TypedEventStreamEnvelopeMailRead = {
*/
export type TypedEventStreamEnvelopeMailReplied = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6153,6 +6238,7 @@ export type TypedEventStreamEnvelopeMailReplied = {
*/
export type TypedEventStreamEnvelopeMailSent = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -6170,6 +6256,7 @@ export type TypedEventStreamEnvelopeMailSent = {
*/
export type TypedEventStreamEnvelopeMoleculeResolved = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MoleculeResolvedPayload;
run_id?: string;
@@ -6187,6 +6274,7 @@ export type TypedEventStreamEnvelopeMoleculeResolved = {
*/
export type TypedEventStreamEnvelopeOrderCompleted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6204,6 +6292,7 @@ export type TypedEventStreamEnvelopeOrderCompleted = {
*/
export type TypedEventStreamEnvelopeOrderFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6221,6 +6310,7 @@ export type TypedEventStreamEnvelopeOrderFailed = {
*/
export type TypedEventStreamEnvelopeOrderFired = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6238,6 +6328,7 @@ export type TypedEventStreamEnvelopeOrderFired = {
*/
export type TypedEventStreamEnvelopeOrderGateTimeoutFailOpen = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OrderGateTimeoutFailOpenPayload;
run_id?: string;
@@ -6255,6 +6346,7 @@ export type TypedEventStreamEnvelopeOrderGateTimeoutFailOpen = {
*/
export type TypedEventStreamEnvelopePgCredentialResolved = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: PostgresCredentialResolvedPayload;
run_id?: string;
@@ -6272,6 +6364,7 @@ export type TypedEventStreamEnvelopePgCredentialResolved = {
*/
export type TypedEventStreamEnvelopeProjectIdentityStamped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ProjectIdentityStampedPayload;
run_id?: string;
@@ -6289,6 +6382,7 @@ export type TypedEventStreamEnvelopeProjectIdentityStamped = {
*/
export type TypedEventStreamEnvelopeProviderQuotaObserved = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: QuotaObservedPayload;
run_id?: string;
@@ -6306,6 +6400,7 @@ export type TypedEventStreamEnvelopeProviderQuotaObserved = {
*/
export type TypedEventStreamEnvelopeProviderQuotaPollFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: QuotaPollFailedPayload;
run_id?: string;
@@ -6323,6 +6418,7 @@ export type TypedEventStreamEnvelopeProviderQuotaPollFailed = {
*/
export type TypedEventStreamEnvelopeProviderSwapped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6340,6 +6436,7 @@ export type TypedEventStreamEnvelopeProviderSwapped = {
*/
export type TypedEventStreamEnvelopeProxyReaped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ProxyReapedPayload;
run_id?: string;
@@ -6357,6 +6454,7 @@ export type TypedEventStreamEnvelopeProxyReaped = {
*/
export type TypedEventStreamEnvelopeRequestFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RequestFailedPayload;
run_id?: string;
@@ -6374,6 +6472,7 @@ export type TypedEventStreamEnvelopeRequestFailed = {
*/
export type TypedEventStreamEnvelopeRequestResultCityCreate = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityCreateSucceededPayload;
run_id?: string;
@@ -6391,6 +6490,7 @@ export type TypedEventStreamEnvelopeRequestResultCityCreate = {
*/
export type TypedEventStreamEnvelopeRequestResultCityUnregister = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityUnregisterSucceededPayload;
run_id?: string;
@@ -6408,6 +6508,7 @@ export type TypedEventStreamEnvelopeRequestResultCityUnregister = {
*/
export type TypedEventStreamEnvelopeRequestResultRigCreate = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RigCreateSucceededPayload;
run_id?: string;
@@ -6425,6 +6526,7 @@ export type TypedEventStreamEnvelopeRequestResultRigCreate = {
*/
export type TypedEventStreamEnvelopeRequestResultSessionCreate = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionCreateSucceededPayload;
run_id?: string;
@@ -6442,6 +6544,7 @@ export type TypedEventStreamEnvelopeRequestResultSessionCreate = {
*/
export type TypedEventStreamEnvelopeRequestResultSessionMessage = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionMessageSucceededPayload;
run_id?: string;
@@ -6459,6 +6562,7 @@ export type TypedEventStreamEnvelopeRequestResultSessionMessage = {
*/
export type TypedEventStreamEnvelopeRequestResultSessionSubmit = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionSubmitSucceededPayload;
run_id?: string;
@@ -6476,6 +6580,7 @@ export type TypedEventStreamEnvelopeRequestResultSessionSubmit = {
*/
export type TypedEventStreamEnvelopeRigProvisionProgress = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RigProvisionProgressPayload;
run_id?: string;
@@ -6493,6 +6598,7 @@ export type TypedEventStreamEnvelopeRigProvisionProgress = {
*/
export type TypedEventStreamEnvelopeSessionColdStartTimeout = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6510,6 +6616,7 @@ export type TypedEventStreamEnvelopeSessionColdStartTimeout = {
*/
export type TypedEventStreamEnvelopeSessionCrashed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -6527,6 +6634,7 @@ export type TypedEventStreamEnvelopeSessionCrashed = {
*/
export type TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionDrainAckedWithAssignedWorkPayload;
run_id?: string;
@@ -6544,6 +6652,7 @@ export type TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = {
*/
export type TypedEventStreamEnvelopeSessionDraining = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6561,6 +6670,7 @@ export type TypedEventStreamEnvelopeSessionDraining = {
*/
export type TypedEventStreamEnvelopeSessionIdleKilled = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6578,6 +6688,7 @@ export type TypedEventStreamEnvelopeSessionIdleKilled = {
*/
export type TypedEventStreamEnvelopeSessionMaxAgeKilled = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6595,6 +6706,7 @@ export type TypedEventStreamEnvelopeSessionMaxAgeKilled = {
*/
export type TypedEventStreamEnvelopeSessionQuarantined = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6612,6 +6724,7 @@ export type TypedEventStreamEnvelopeSessionQuarantined = {
*/
export type TypedEventStreamEnvelopeSessionResetStalled = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionResetStalledPayload;
run_id?: string;
@@ -6629,6 +6742,7 @@ export type TypedEventStreamEnvelopeSessionResetStalled = {
*/
export type TypedEventStreamEnvelopeSessionStopped = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -6646,6 +6760,7 @@ export type TypedEventStreamEnvelopeSessionStopped = {
*/
export type TypedEventStreamEnvelopeSessionStranded = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionStrandedPayload;
run_id?: string;
@@ -6663,6 +6778,7 @@ export type TypedEventStreamEnvelopeSessionStranded = {
*/
export type TypedEventStreamEnvelopeSessionSuspended = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6680,6 +6796,7 @@ export type TypedEventStreamEnvelopeSessionSuspended = {
*/
export type TypedEventStreamEnvelopeSessionUndrained = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6697,6 +6814,7 @@ export type TypedEventStreamEnvelopeSessionUndrained = {
*/
export type TypedEventStreamEnvelopeSessionUnknownState = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionUnknownStatePayload;
run_id?: string;
@@ -6714,6 +6832,7 @@ export type TypedEventStreamEnvelopeSessionUnknownState = {
*/
export type TypedEventStreamEnvelopeSessionUpdated = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6731,6 +6850,7 @@ export type TypedEventStreamEnvelopeSessionUpdated = {
*/
export type TypedEventStreamEnvelopeSessionWoke = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -6748,6 +6868,7 @@ export type TypedEventStreamEnvelopeSessionWoke = {
*/
export type TypedEventStreamEnvelopeSessionWorkQueryFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -6765,6 +6886,7 @@ export type TypedEventStreamEnvelopeSessionWorkQueryFailed = {
*/
export type TypedEventStreamEnvelopeStoreDegraded = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDegradedPayload;
run_id?: string;
@@ -6782,6 +6904,7 @@ export type TypedEventStreamEnvelopeStoreDegraded = {
*/
export type TypedEventStreamEnvelopeStoreProbeFailed = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreProbeFailedPayload;
run_id?: string;
@@ -6799,6 +6922,7 @@ export type TypedEventStreamEnvelopeStoreProbeFailed = {
*/
export type TypedEventStreamEnvelopeStoreRecovered = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreRecoveredPayload;
run_id?: string;
@@ -6816,6 +6940,7 @@ export type TypedEventStreamEnvelopeStoreRecovered = {
*/
export type TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorFsPressureSkippedTickPayload;
run_id?: string;
@@ -6833,6 +6958,7 @@ export type TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick = {
*/
export type TypedEventStreamEnvelopeSupervisorRequest = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorRequestPayload;
run_id?: string;
@@ -6850,6 +6976,7 @@ export type TypedEventStreamEnvelopeSupervisorRequest = {
*/
export type TypedEventStreamEnvelopeSupervisorShutdownRequested = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorShutdownPayload;
run_id?: string;
@@ -6867,6 +6994,7 @@ export type TypedEventStreamEnvelopeSupervisorShutdownRequested = {
*/
export type TypedEventStreamEnvelopeSupervisorStarted = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorStartedPayload;
run_id?: string;
@@ -6884,6 +7012,7 @@ export type TypedEventStreamEnvelopeSupervisorStarted = {
*/
export type TypedEventStreamEnvelopeWebhookReceived = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WebhookReceivedPayload;
run_id?: string;
@@ -6901,6 +7030,7 @@ export type TypedEventStreamEnvelopeWebhookReceived = {
*/
export type TypedEventStreamEnvelopeWebhookRejected = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WebhookRejectedPayload;
run_id?: string;
@@ -6918,6 +7048,7 @@ export type TypedEventStreamEnvelopeWebhookRejected = {
*/
export type TypedEventStreamEnvelopeWorkerOperation = {
actor: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WorkerOperationEventPayload;
run_id?: string;
@@ -6982,6 +7113,10 @@ export type TypedTaggedEventStreamEnvelope = ({
} & TypedTaggedEventStreamEnvelopeEmergencySignaled) | ({
type: 'events.rotated';
} & TypedTaggedEventStreamEnvelopeEventsRotated) | ({
+ type: 'execution.step_defined';
+} & TypedTaggedEventStreamEnvelopeExecutionStepDefined) | ({
+ type: 'execution.work_associated';
+} & TypedTaggedEventStreamEnvelopeExecutionWorkAssociated) | ({
type: 'extmsg.adapter_added';
} & TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded) | ({
type: 'extmsg.adapter_removed';
@@ -7119,6 +7254,7 @@ export type TypedTaggedEventStreamEnvelope = ({
export type TypedTaggedEventStreamEnvelopeBeadClaimRejected = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadClaimRejectedPayload;
run_id?: string;
@@ -7137,6 +7273,7 @@ export type TypedTaggedEventStreamEnvelopeBeadClaimRejected = {
export type TypedTaggedEventStreamEnvelopeBeadClosed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -7155,6 +7292,7 @@ export type TypedTaggedEventStreamEnvelopeBeadClosed = {
export type TypedTaggedEventStreamEnvelopeBeadCreated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -7173,6 +7311,7 @@ export type TypedTaggedEventStreamEnvelopeBeadCreated = {
export type TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadDeadAssigneeReopenedPayload;
run_id?: string;
@@ -7191,6 +7330,7 @@ export type TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened = {
export type TypedTaggedEventStreamEnvelopeBeadDeleted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -7209,6 +7349,7 @@ export type TypedTaggedEventStreamEnvelopeBeadDeleted = {
export type TypedTaggedEventStreamEnvelopeBeadUpdated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadEventPayload;
run_id?: string;
@@ -7227,6 +7368,7 @@ export type TypedTaggedEventStreamEnvelopeBeadUpdated = {
export type TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadWorktreeReapSkippedPayload;
run_id?: string;
@@ -7245,6 +7387,7 @@ export type TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped = {
export type TypedTaggedEventStreamEnvelopeBeadWorktreeReaped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BeadWorktreeReapedPayload;
run_id?: string;
@@ -7263,6 +7406,7 @@ export type TypedTaggedEventStreamEnvelopeBeadWorktreeReaped = {
export type TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ConditionalWritesDegradedPayload;
run_id?: string;
@@ -7281,6 +7425,7 @@ export type TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded = {
export type TypedTaggedEventStreamEnvelopeBreakerStateChanged = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BreakerStateChangedPayload;
run_id?: string;
@@ -7299,6 +7444,7 @@ export type TypedTaggedEventStreamEnvelopeBreakerStateChanged = {
export type TypedTaggedEventStreamEnvelopeCityCreated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityLifecyclePayload;
run_id?: string;
@@ -7317,6 +7463,7 @@ export type TypedTaggedEventStreamEnvelopeCityCreated = {
export type TypedTaggedEventStreamEnvelopeCityResumed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7335,6 +7482,7 @@ export type TypedTaggedEventStreamEnvelopeCityResumed = {
export type TypedTaggedEventStreamEnvelopeCitySuspended = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7353,6 +7501,7 @@ export type TypedTaggedEventStreamEnvelopeCitySuspended = {
export type TypedTaggedEventStreamEnvelopeCityUnregisterRequested = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityLifecyclePayload;
run_id?: string;
@@ -7371,6 +7520,7 @@ export type TypedTaggedEventStreamEnvelopeCityUnregisterRequested = {
export type TypedTaggedEventStreamEnvelopeControllerStarted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7389,6 +7539,7 @@ export type TypedTaggedEventStreamEnvelopeControllerStarted = {
export type TypedTaggedEventStreamEnvelopeControllerStopped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7407,6 +7558,7 @@ export type TypedTaggedEventStreamEnvelopeControllerStopped = {
export type TypedTaggedEventStreamEnvelopeControllerTickCompleted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ControllerTickCompletedPayload;
run_id?: string;
@@ -7425,6 +7577,7 @@ export type TypedTaggedEventStreamEnvelopeControllerTickCompleted = {
export type TypedTaggedEventStreamEnvelopeConvoyClosed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7443,6 +7596,7 @@ export type TypedTaggedEventStreamEnvelopeConvoyClosed = {
export type TypedTaggedEventStreamEnvelopeConvoyCreated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7461,6 +7615,7 @@ export type TypedTaggedEventStreamEnvelopeConvoyCreated = {
export type TypedTaggedEventStreamEnvelopeCustom = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: unknown;
run_id?: string;
@@ -7479,6 +7634,7 @@ export type TypedTaggedEventStreamEnvelopeCustom = {
export type TypedTaggedEventStreamEnvelopeDoctorAlert = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: DoctorAlertPayload;
run_id?: string;
@@ -7497,6 +7653,7 @@ export type TypedTaggedEventStreamEnvelopeDoctorAlert = {
export type TypedTaggedEventStreamEnvelopeEmergencyAcked = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: Record;
run_id?: string;
@@ -7515,6 +7672,7 @@ export type TypedTaggedEventStreamEnvelopeEmergencyAcked = {
export type TypedTaggedEventStreamEnvelopeEmergencySignaled = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: Record;
run_id?: string;
@@ -7533,6 +7691,7 @@ export type TypedTaggedEventStreamEnvelopeEmergencySignaled = {
export type TypedTaggedEventStreamEnvelopeEventsRotated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RotatedPayload;
run_id?: string;
@@ -7545,12 +7704,51 @@ export type TypedTaggedEventStreamEnvelopeEventsRotated = {
workflow?: WorkflowEventProjection;
};
+/**
+ * TypedTaggedEventStreamEnvelope execution.step_defined
+ */
+export type TypedTaggedEventStreamEnvelopeExecutionStepDefined = {
+ actor: string;
+ city: string;
+ depends_on_step_ids?: Array;
+ message?: string;
+ payload: NoPayload;
+ run_id?: string;
+ seq: number;
+ session_id?: string;
+ step_id?: string;
+ subject?: string;
+ ts: string;
+ type: 'execution.step_defined';
+ workflow?: WorkflowEventProjection;
+};
+
+/**
+ * TypedTaggedEventStreamEnvelope execution.work_associated
+ */
+export type TypedTaggedEventStreamEnvelopeExecutionWorkAssociated = {
+ actor: string;
+ city: string;
+ depends_on_step_ids?: Array;
+ message?: string;
+ payload: NoPayload;
+ run_id?: string;
+ seq: number;
+ session_id?: string;
+ step_id?: string;
+ subject?: string;
+ ts: string;
+ type: 'execution.work_associated';
+ workflow?: WorkflowEventProjection;
+};
+
/**
* TypedTaggedEventStreamEnvelope extmsg.adapter_added
*/
export type TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: AdapterEventPayload;
run_id?: string;
@@ -7569,6 +7767,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded = {
export type TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: AdapterEventPayload;
run_id?: string;
@@ -7587,6 +7786,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved = {
export type TypedTaggedEventStreamEnvelopeExtmsgBound = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: BoundEventPayload;
run_id?: string;
@@ -7605,6 +7805,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgBound = {
export type TypedTaggedEventStreamEnvelopeExtmsgGroupCreated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: GroupCreatedEventPayload;
run_id?: string;
@@ -7623,6 +7824,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgGroupCreated = {
export type TypedTaggedEventStreamEnvelopeExtmsgInbound = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: InboundEventPayload;
run_id?: string;
@@ -7641,6 +7843,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgInbound = {
export type TypedTaggedEventStreamEnvelopeExtmsgOutbound = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OutboundEventPayload;
run_id?: string;
@@ -7659,6 +7862,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgOutbound = {
export type TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OutboundChannelMismatchPayload;
run_id?: string;
@@ -7677,6 +7881,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch = {
export type TypedTaggedEventStreamEnvelopeExtmsgUnbound = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: UnboundEventPayload;
run_id?: string;
@@ -7695,6 +7900,7 @@ export type TypedTaggedEventStreamEnvelopeExtmsgUnbound = {
export type TypedTaggedEventStreamEnvelopeGcStoreDiskCritical = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDiskCriticalPayload;
run_id?: string;
@@ -7713,6 +7919,7 @@ export type TypedTaggedEventStreamEnvelopeGcStoreDiskCritical = {
export type TypedTaggedEventStreamEnvelopeGcStoreDiskWarn = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDiskWarnPayload;
run_id?: string;
@@ -7731,6 +7938,7 @@ export type TypedTaggedEventStreamEnvelopeGcStoreDiskWarn = {
export type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreMaintenanceDonePayload;
run_id?: string;
@@ -7749,6 +7957,7 @@ export type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone = {
export type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreMaintenanceFailedPayload;
run_id?: string;
@@ -7767,6 +7976,7 @@ export type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed = {
export type TypedTaggedEventStreamEnvelopeMailArchived = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7785,6 +7995,7 @@ export type TypedTaggedEventStreamEnvelopeMailArchived = {
export type TypedTaggedEventStreamEnvelopeMailDeleted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7803,6 +8014,7 @@ export type TypedTaggedEventStreamEnvelopeMailDeleted = {
export type TypedTaggedEventStreamEnvelopeMailMarkedRead = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7821,6 +8033,7 @@ export type TypedTaggedEventStreamEnvelopeMailMarkedRead = {
export type TypedTaggedEventStreamEnvelopeMailMarkedUnread = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7839,6 +8052,7 @@ export type TypedTaggedEventStreamEnvelopeMailMarkedUnread = {
export type TypedTaggedEventStreamEnvelopeMailRead = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7857,6 +8071,7 @@ export type TypedTaggedEventStreamEnvelopeMailRead = {
export type TypedTaggedEventStreamEnvelopeMailReplied = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7875,6 +8090,7 @@ export type TypedTaggedEventStreamEnvelopeMailReplied = {
export type TypedTaggedEventStreamEnvelopeMailSent = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MailEventPayload;
run_id?: string;
@@ -7893,6 +8109,7 @@ export type TypedTaggedEventStreamEnvelopeMailSent = {
export type TypedTaggedEventStreamEnvelopeMoleculeResolved = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: MoleculeResolvedPayload;
run_id?: string;
@@ -7911,6 +8128,7 @@ export type TypedTaggedEventStreamEnvelopeMoleculeResolved = {
export type TypedTaggedEventStreamEnvelopeOrderCompleted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7929,6 +8147,7 @@ export type TypedTaggedEventStreamEnvelopeOrderCompleted = {
export type TypedTaggedEventStreamEnvelopeOrderFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7947,6 +8166,7 @@ export type TypedTaggedEventStreamEnvelopeOrderFailed = {
export type TypedTaggedEventStreamEnvelopeOrderFired = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -7965,6 +8185,7 @@ export type TypedTaggedEventStreamEnvelopeOrderFired = {
export type TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: OrderGateTimeoutFailOpenPayload;
run_id?: string;
@@ -7983,6 +8204,7 @@ export type TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen = {
export type TypedTaggedEventStreamEnvelopePgCredentialResolved = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: PostgresCredentialResolvedPayload;
run_id?: string;
@@ -8001,6 +8223,7 @@ export type TypedTaggedEventStreamEnvelopePgCredentialResolved = {
export type TypedTaggedEventStreamEnvelopeProjectIdentityStamped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ProjectIdentityStampedPayload;
run_id?: string;
@@ -8019,6 +8242,7 @@ export type TypedTaggedEventStreamEnvelopeProjectIdentityStamped = {
export type TypedTaggedEventStreamEnvelopeProviderQuotaObserved = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: QuotaObservedPayload;
run_id?: string;
@@ -8037,6 +8261,7 @@ export type TypedTaggedEventStreamEnvelopeProviderQuotaObserved = {
export type TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: QuotaPollFailedPayload;
run_id?: string;
@@ -8055,6 +8280,7 @@ export type TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed = {
export type TypedTaggedEventStreamEnvelopeProviderSwapped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8073,6 +8299,7 @@ export type TypedTaggedEventStreamEnvelopeProviderSwapped = {
export type TypedTaggedEventStreamEnvelopeProxyReaped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: ProxyReapedPayload;
run_id?: string;
@@ -8091,6 +8318,7 @@ export type TypedTaggedEventStreamEnvelopeProxyReaped = {
export type TypedTaggedEventStreamEnvelopeRequestFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RequestFailedPayload;
run_id?: string;
@@ -8109,6 +8337,7 @@ export type TypedTaggedEventStreamEnvelopeRequestFailed = {
export type TypedTaggedEventStreamEnvelopeRequestResultCityCreate = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityCreateSucceededPayload;
run_id?: string;
@@ -8127,6 +8356,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultCityCreate = {
export type TypedTaggedEventStreamEnvelopeRequestResultCityUnregister = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: CityUnregisterSucceededPayload;
run_id?: string;
@@ -8145,6 +8375,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultCityUnregister = {
export type TypedTaggedEventStreamEnvelopeRequestResultRigCreate = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RigCreateSucceededPayload;
run_id?: string;
@@ -8163,6 +8394,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultRigCreate = {
export type TypedTaggedEventStreamEnvelopeRequestResultSessionCreate = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionCreateSucceededPayload;
run_id?: string;
@@ -8181,6 +8413,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultSessionCreate = {
export type TypedTaggedEventStreamEnvelopeRequestResultSessionMessage = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionMessageSucceededPayload;
run_id?: string;
@@ -8199,6 +8432,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultSessionMessage = {
export type TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionSubmitSucceededPayload;
run_id?: string;
@@ -8217,6 +8451,7 @@ export type TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit = {
export type TypedTaggedEventStreamEnvelopeRigProvisionProgress = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: RigProvisionProgressPayload;
run_id?: string;
@@ -8235,6 +8470,7 @@ export type TypedTaggedEventStreamEnvelopeRigProvisionProgress = {
export type TypedTaggedEventStreamEnvelopeSessionColdStartTimeout = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8253,6 +8489,7 @@ export type TypedTaggedEventStreamEnvelopeSessionColdStartTimeout = {
export type TypedTaggedEventStreamEnvelopeSessionCrashed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -8271,6 +8508,7 @@ export type TypedTaggedEventStreamEnvelopeSessionCrashed = {
export type TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionDrainAckedWithAssignedWorkPayload;
run_id?: string;
@@ -8289,6 +8527,7 @@ export type TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = {
export type TypedTaggedEventStreamEnvelopeSessionDraining = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8307,6 +8546,7 @@ export type TypedTaggedEventStreamEnvelopeSessionDraining = {
export type TypedTaggedEventStreamEnvelopeSessionIdleKilled = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8325,6 +8565,7 @@ export type TypedTaggedEventStreamEnvelopeSessionIdleKilled = {
export type TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8343,6 +8584,7 @@ export type TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled = {
export type TypedTaggedEventStreamEnvelopeSessionQuarantined = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8361,6 +8603,7 @@ export type TypedTaggedEventStreamEnvelopeSessionQuarantined = {
export type TypedTaggedEventStreamEnvelopeSessionResetStalled = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionResetStalledPayload;
run_id?: string;
@@ -8379,6 +8622,7 @@ export type TypedTaggedEventStreamEnvelopeSessionResetStalled = {
export type TypedTaggedEventStreamEnvelopeSessionStopped = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -8397,6 +8641,7 @@ export type TypedTaggedEventStreamEnvelopeSessionStopped = {
export type TypedTaggedEventStreamEnvelopeSessionStranded = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionStrandedPayload;
run_id?: string;
@@ -8415,6 +8660,7 @@ export type TypedTaggedEventStreamEnvelopeSessionStranded = {
export type TypedTaggedEventStreamEnvelopeSessionSuspended = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8433,6 +8679,7 @@ export type TypedTaggedEventStreamEnvelopeSessionSuspended = {
export type TypedTaggedEventStreamEnvelopeSessionUndrained = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8451,6 +8698,7 @@ export type TypedTaggedEventStreamEnvelopeSessionUndrained = {
export type TypedTaggedEventStreamEnvelopeSessionUnknownState = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionUnknownStatePayload;
run_id?: string;
@@ -8469,6 +8717,7 @@ export type TypedTaggedEventStreamEnvelopeSessionUnknownState = {
export type TypedTaggedEventStreamEnvelopeSessionUpdated = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8487,6 +8736,7 @@ export type TypedTaggedEventStreamEnvelopeSessionUpdated = {
export type TypedTaggedEventStreamEnvelopeSessionWoke = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: NoPayload;
run_id?: string;
@@ -8505,6 +8755,7 @@ export type TypedTaggedEventStreamEnvelopeSessionWoke = {
export type TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SessionLifecyclePayload;
run_id?: string;
@@ -8523,6 +8774,7 @@ export type TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed = {
export type TypedTaggedEventStreamEnvelopeStoreDegraded = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreDegradedPayload;
run_id?: string;
@@ -8541,6 +8793,7 @@ export type TypedTaggedEventStreamEnvelopeStoreDegraded = {
export type TypedTaggedEventStreamEnvelopeStoreProbeFailed = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreProbeFailedPayload;
run_id?: string;
@@ -8559,6 +8812,7 @@ export type TypedTaggedEventStreamEnvelopeStoreProbeFailed = {
export type TypedTaggedEventStreamEnvelopeStoreRecovered = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: StoreRecoveredPayload;
run_id?: string;
@@ -8577,6 +8831,7 @@ export type TypedTaggedEventStreamEnvelopeStoreRecovered = {
export type TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorFsPressureSkippedTickPayload;
run_id?: string;
@@ -8595,6 +8850,7 @@ export type TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick = {
export type TypedTaggedEventStreamEnvelopeSupervisorRequest = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorRequestPayload;
run_id?: string;
@@ -8613,6 +8869,7 @@ export type TypedTaggedEventStreamEnvelopeSupervisorRequest = {
export type TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorShutdownPayload;
run_id?: string;
@@ -8631,6 +8888,7 @@ export type TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested = {
export type TypedTaggedEventStreamEnvelopeSupervisorStarted = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: SupervisorStartedPayload;
run_id?: string;
@@ -8649,6 +8907,7 @@ export type TypedTaggedEventStreamEnvelopeSupervisorStarted = {
export type TypedTaggedEventStreamEnvelopeWebhookReceived = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WebhookReceivedPayload;
run_id?: string;
@@ -8667,6 +8926,7 @@ export type TypedTaggedEventStreamEnvelopeWebhookReceived = {
export type TypedTaggedEventStreamEnvelopeWebhookRejected = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WebhookRejectedPayload;
run_id?: string;
@@ -8685,6 +8945,7 @@ export type TypedTaggedEventStreamEnvelopeWebhookRejected = {
export type TypedTaggedEventStreamEnvelopeWorkerOperation = {
actor: string;
city: string;
+ depends_on_step_ids?: Array;
message?: string;
payload: WorkerOperationEventPayload;
run_id?: string;
diff --git a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/zod.gen.ts b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/zod.gen.ts
index 08b8df73a4..32000c3907 100644
--- a/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/zod.gen.ts
+++ b/internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/zod.gen.ts
@@ -1054,6 +1054,7 @@ export const zPoolOverride = z.object({
export const zAgentPatch = z.object({
AppendFragments: z.array(z.string()).nullable(),
Args: z.array(z.string()).nullable(),
+ AssignedWorkDeferLimit: z.coerce.bigint().min(BigInt('-9223372036854775808'), { error: 'Invalid value: Expected int64 to be >= -9223372036854775808' }).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }).nullable(),
Attach: z.boolean().nullable(),
DefaultSlingFormula: z.string().nullable(),
DependsOn: z.array(z.string()).nullable(),
@@ -3390,6 +3391,7 @@ export const zWorkflowEventProjection = z.object({
export const zEventStreamEnvelope = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zEventPayload.optional(),
run_id: z.string().optional(),
@@ -3405,6 +3407,7 @@ export const zEventStreamEnvelope = z.object({
export const zTaggedEventStreamEnvelope = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zEventPayload.optional(),
run_id: z.string().optional(),
@@ -3422,6 +3425,7 @@ export const zTaggedEventStreamEnvelope = z.object({
*/
export const zTypedEventStreamEnvelopeBeadClaimRejected = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadClaimRejectedPayload,
run_id: z.string().optional(),
@@ -3439,6 +3443,7 @@ export const zTypedEventStreamEnvelopeBeadClaimRejected = z.object({
*/
export const zTypedEventStreamEnvelopeBeadClosed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -3456,6 +3461,7 @@ export const zTypedEventStreamEnvelopeBeadClosed = z.object({
*/
export const zTypedEventStreamEnvelopeBeadCreated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -3473,6 +3479,7 @@ export const zTypedEventStreamEnvelopeBeadCreated = z.object({
*/
export const zTypedEventStreamEnvelopeBeadDeadAssigneeReopened = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadDeadAssigneeReopenedPayload,
run_id: z.string().optional(),
@@ -3490,6 +3497,7 @@ export const zTypedEventStreamEnvelopeBeadDeadAssigneeReopened = z.object({
*/
export const zTypedEventStreamEnvelopeBeadDeleted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -3507,6 +3515,7 @@ export const zTypedEventStreamEnvelopeBeadDeleted = z.object({
*/
export const zTypedEventStreamEnvelopeBeadUpdated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -3524,6 +3533,7 @@ export const zTypedEventStreamEnvelopeBeadUpdated = z.object({
*/
export const zTypedEventStreamEnvelopeBeadWorktreeReapSkipped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadWorktreeReapSkippedPayload,
run_id: z.string().optional(),
@@ -3541,6 +3551,7 @@ export const zTypedEventStreamEnvelopeBeadWorktreeReapSkipped = z.object({
*/
export const zTypedEventStreamEnvelopeBeadWorktreeReaped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadWorktreeReapedPayload,
run_id: z.string().optional(),
@@ -3558,6 +3569,7 @@ export const zTypedEventStreamEnvelopeBeadWorktreeReaped = z.object({
*/
export const zTypedEventStreamEnvelopeBeadsConditionalWritesDegraded = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zConditionalWritesDegradedPayload,
run_id: z.string().optional(),
@@ -3575,6 +3587,7 @@ export const zTypedEventStreamEnvelopeBeadsConditionalWritesDegraded = z.object(
*/
export const zTypedEventStreamEnvelopeBreakerStateChanged = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBreakerStateChangedPayload,
run_id: z.string().optional(),
@@ -3592,6 +3605,7 @@ export const zTypedEventStreamEnvelopeBreakerStateChanged = z.object({
*/
export const zTypedEventStreamEnvelopeCityCreated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityLifecyclePayload,
run_id: z.string().optional(),
@@ -3609,6 +3623,7 @@ export const zTypedEventStreamEnvelopeCityCreated = z.object({
*/
export const zTypedEventStreamEnvelopeCityResumed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3626,6 +3641,7 @@ export const zTypedEventStreamEnvelopeCityResumed = z.object({
*/
export const zTypedEventStreamEnvelopeCitySuspended = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3643,6 +3659,7 @@ export const zTypedEventStreamEnvelopeCitySuspended = z.object({
*/
export const zTypedEventStreamEnvelopeCityUnregisterRequested = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityLifecyclePayload,
run_id: z.string().optional(),
@@ -3660,6 +3677,7 @@ export const zTypedEventStreamEnvelopeCityUnregisterRequested = z.object({
*/
export const zTypedEventStreamEnvelopeControllerStarted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3677,6 +3695,7 @@ export const zTypedEventStreamEnvelopeControllerStarted = z.object({
*/
export const zTypedEventStreamEnvelopeControllerStopped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3694,6 +3713,7 @@ export const zTypedEventStreamEnvelopeControllerStopped = z.object({
*/
export const zTypedEventStreamEnvelopeControllerTickCompleted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zControllerTickCompletedPayload,
run_id: z.string().optional(),
@@ -3711,6 +3731,7 @@ export const zTypedEventStreamEnvelopeControllerTickCompleted = z.object({
*/
export const zTypedEventStreamEnvelopeConvoyClosed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3728,6 +3749,7 @@ export const zTypedEventStreamEnvelopeConvoyClosed = z.object({
*/
export const zTypedEventStreamEnvelopeConvoyCreated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -3745,6 +3767,7 @@ export const zTypedEventStreamEnvelopeConvoyCreated = z.object({
*/
export const zTypedEventStreamEnvelopeCustom = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: z.unknown(),
run_id: z.string().optional(),
@@ -3762,6 +3785,7 @@ export const zTypedEventStreamEnvelopeCustom = z.object({
*/
export const zTypedEventStreamEnvelopeDoctorAlert = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zDoctorAlertPayload,
run_id: z.string().optional(),
@@ -3779,6 +3803,7 @@ export const zTypedEventStreamEnvelopeDoctorAlert = z.object({
*/
export const zTypedEventStreamEnvelopeEmergencyAcked = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRecord,
run_id: z.string().optional(),
@@ -3796,6 +3821,7 @@ export const zTypedEventStreamEnvelopeEmergencyAcked = z.object({
*/
export const zTypedEventStreamEnvelopeEmergencySignaled = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRecord,
run_id: z.string().optional(),
@@ -3813,6 +3839,7 @@ export const zTypedEventStreamEnvelopeEmergencySignaled = z.object({
*/
export const zTypedEventStreamEnvelopeEventsRotated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRotatedPayload,
run_id: z.string().optional(),
@@ -3825,11 +3852,48 @@ export const zTypedEventStreamEnvelopeEventsRotated = z.object({
workflow: zWorkflowEventProjection.optional()
});
+/**
+ * TypedEventStreamEnvelope execution.step_defined
+ */
+export const zTypedEventStreamEnvelopeExecutionStepDefined = z.object({
+ actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
+ message: z.string().optional(),
+ payload: zNoPayload,
+ run_id: z.string().optional(),
+ seq: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }),
+ session_id: z.string().optional(),
+ step_id: z.string().optional(),
+ subject: z.string().optional(),
+ ts: z.iso.datetime(),
+ type: z.literal('execution.step_defined'),
+ workflow: zWorkflowEventProjection.optional()
+});
+
+/**
+ * TypedEventStreamEnvelope execution.work_associated
+ */
+export const zTypedEventStreamEnvelopeExecutionWorkAssociated = z.object({
+ actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
+ message: z.string().optional(),
+ payload: zNoPayload,
+ run_id: z.string().optional(),
+ seq: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }),
+ session_id: z.string().optional(),
+ step_id: z.string().optional(),
+ subject: z.string().optional(),
+ ts: z.iso.datetime(),
+ type: z.literal('execution.work_associated'),
+ workflow: zWorkflowEventProjection.optional()
+});
+
/**
* TypedEventStreamEnvelope extmsg.adapter_added
*/
export const zTypedEventStreamEnvelopeExtmsgAdapterAdded = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zAdapterEventPayload,
run_id: z.string().optional(),
@@ -3847,6 +3911,7 @@ export const zTypedEventStreamEnvelopeExtmsgAdapterAdded = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgAdapterRemoved = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zAdapterEventPayload,
run_id: z.string().optional(),
@@ -3864,6 +3929,7 @@ export const zTypedEventStreamEnvelopeExtmsgAdapterRemoved = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgBound = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBoundEventPayload,
run_id: z.string().optional(),
@@ -3881,6 +3947,7 @@ export const zTypedEventStreamEnvelopeExtmsgBound = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgGroupCreated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zGroupCreatedEventPayload,
run_id: z.string().optional(),
@@ -3898,6 +3965,7 @@ export const zTypedEventStreamEnvelopeExtmsgGroupCreated = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgInbound = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zInboundEventPayload,
run_id: z.string().optional(),
@@ -3915,6 +3983,7 @@ export const zTypedEventStreamEnvelopeExtmsgInbound = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgOutbound = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOutboundEventPayload,
run_id: z.string().optional(),
@@ -3932,6 +4001,7 @@ export const zTypedEventStreamEnvelopeExtmsgOutbound = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgOutboundChannelMismatch = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOutboundChannelMismatchPayload,
run_id: z.string().optional(),
@@ -3949,6 +4019,7 @@ export const zTypedEventStreamEnvelopeExtmsgOutboundChannelMismatch = z.object({
*/
export const zTypedEventStreamEnvelopeExtmsgUnbound = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zUnboundEventPayload,
run_id: z.string().optional(),
@@ -3966,6 +4037,7 @@ export const zTypedEventStreamEnvelopeExtmsgUnbound = z.object({
*/
export const zTypedEventStreamEnvelopeGcStoreDiskCritical = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDiskCriticalPayload,
run_id: z.string().optional(),
@@ -3983,6 +4055,7 @@ export const zTypedEventStreamEnvelopeGcStoreDiskCritical = z.object({
*/
export const zTypedEventStreamEnvelopeGcStoreDiskWarn = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDiskWarnPayload,
run_id: z.string().optional(),
@@ -4000,6 +4073,7 @@ export const zTypedEventStreamEnvelopeGcStoreDiskWarn = z.object({
*/
export const zTypedEventStreamEnvelopeGcStoreMaintenanceDone = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreMaintenanceDonePayload,
run_id: z.string().optional(),
@@ -4017,6 +4091,7 @@ export const zTypedEventStreamEnvelopeGcStoreMaintenanceDone = z.object({
*/
export const zTypedEventStreamEnvelopeGcStoreMaintenanceFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreMaintenanceFailedPayload,
run_id: z.string().optional(),
@@ -4034,6 +4109,7 @@ export const zTypedEventStreamEnvelopeGcStoreMaintenanceFailed = z.object({
*/
export const zTypedEventStreamEnvelopeMailArchived = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4051,6 +4127,7 @@ export const zTypedEventStreamEnvelopeMailArchived = z.object({
*/
export const zTypedEventStreamEnvelopeMailDeleted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4068,6 +4145,7 @@ export const zTypedEventStreamEnvelopeMailDeleted = z.object({
*/
export const zTypedEventStreamEnvelopeMailMarkedRead = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4085,6 +4163,7 @@ export const zTypedEventStreamEnvelopeMailMarkedRead = z.object({
*/
export const zTypedEventStreamEnvelopeMailMarkedUnread = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4102,6 +4181,7 @@ export const zTypedEventStreamEnvelopeMailMarkedUnread = z.object({
*/
export const zTypedEventStreamEnvelopeMailRead = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4119,6 +4199,7 @@ export const zTypedEventStreamEnvelopeMailRead = z.object({
*/
export const zTypedEventStreamEnvelopeMailReplied = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4136,6 +4217,7 @@ export const zTypedEventStreamEnvelopeMailReplied = z.object({
*/
export const zTypedEventStreamEnvelopeMailSent = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -4153,6 +4235,7 @@ export const zTypedEventStreamEnvelopeMailSent = z.object({
*/
export const zTypedEventStreamEnvelopeMoleculeResolved = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMoleculeResolvedPayload,
run_id: z.string().optional(),
@@ -4170,6 +4253,7 @@ export const zTypedEventStreamEnvelopeMoleculeResolved = z.object({
*/
export const zTypedEventStreamEnvelopeOrderCompleted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4187,6 +4271,7 @@ export const zTypedEventStreamEnvelopeOrderCompleted = z.object({
*/
export const zTypedEventStreamEnvelopeOrderFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4204,6 +4289,7 @@ export const zTypedEventStreamEnvelopeOrderFailed = z.object({
*/
export const zTypedEventStreamEnvelopeOrderFired = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4221,6 +4307,7 @@ export const zTypedEventStreamEnvelopeOrderFired = z.object({
*/
export const zTypedEventStreamEnvelopeOrderGateTimeoutFailOpen = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOrderGateTimeoutFailOpenPayload,
run_id: z.string().optional(),
@@ -4238,6 +4325,7 @@ export const zTypedEventStreamEnvelopeOrderGateTimeoutFailOpen = z.object({
*/
export const zTypedEventStreamEnvelopePgCredentialResolved = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zPostgresCredentialResolvedPayload,
run_id: z.string().optional(),
@@ -4255,6 +4343,7 @@ export const zTypedEventStreamEnvelopePgCredentialResolved = z.object({
*/
export const zTypedEventStreamEnvelopeProjectIdentityStamped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zProjectIdentityStampedPayload,
run_id: z.string().optional(),
@@ -4272,6 +4361,7 @@ export const zTypedEventStreamEnvelopeProjectIdentityStamped = z.object({
*/
export const zTypedEventStreamEnvelopeProviderQuotaObserved = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zQuotaObservedPayload,
run_id: z.string().optional(),
@@ -4289,6 +4379,7 @@ export const zTypedEventStreamEnvelopeProviderQuotaObserved = z.object({
*/
export const zTypedEventStreamEnvelopeProviderQuotaPollFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zQuotaPollFailedPayload,
run_id: z.string().optional(),
@@ -4306,6 +4397,7 @@ export const zTypedEventStreamEnvelopeProviderQuotaPollFailed = z.object({
*/
export const zTypedEventStreamEnvelopeProviderSwapped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4323,6 +4415,7 @@ export const zTypedEventStreamEnvelopeProviderSwapped = z.object({
*/
export const zTypedEventStreamEnvelopeProxyReaped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zProxyReapedPayload,
run_id: z.string().optional(),
@@ -4340,6 +4433,7 @@ export const zTypedEventStreamEnvelopeProxyReaped = z.object({
*/
export const zTypedEventStreamEnvelopeRequestFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRequestFailedPayload,
run_id: z.string().optional(),
@@ -4357,6 +4451,7 @@ export const zTypedEventStreamEnvelopeRequestFailed = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultCityCreate = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityCreateSucceededPayload,
run_id: z.string().optional(),
@@ -4374,6 +4469,7 @@ export const zTypedEventStreamEnvelopeRequestResultCityCreate = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultCityUnregister = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityUnregisterSucceededPayload,
run_id: z.string().optional(),
@@ -4391,6 +4487,7 @@ export const zTypedEventStreamEnvelopeRequestResultCityUnregister = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultRigCreate = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRigCreateSucceededPayload,
run_id: z.string().optional(),
@@ -4408,6 +4505,7 @@ export const zTypedEventStreamEnvelopeRequestResultRigCreate = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultSessionCreate = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionCreateSucceededPayload,
run_id: z.string().optional(),
@@ -4425,6 +4523,7 @@ export const zTypedEventStreamEnvelopeRequestResultSessionCreate = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultSessionMessage = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionMessageSucceededPayload,
run_id: z.string().optional(),
@@ -4442,6 +4541,7 @@ export const zTypedEventStreamEnvelopeRequestResultSessionMessage = z.object({
*/
export const zTypedEventStreamEnvelopeRequestResultSessionSubmit = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionSubmitSucceededPayload,
run_id: z.string().optional(),
@@ -4459,6 +4559,7 @@ export const zTypedEventStreamEnvelopeRequestResultSessionSubmit = z.object({
*/
export const zTypedEventStreamEnvelopeRigProvisionProgress = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRigProvisionProgressPayload,
run_id: z.string().optional(),
@@ -4476,6 +4577,7 @@ export const zTypedEventStreamEnvelopeRigProvisionProgress = z.object({
*/
export const zTypedEventStreamEnvelopeSessionColdStartTimeout = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4493,6 +4595,7 @@ export const zTypedEventStreamEnvelopeSessionColdStartTimeout = z.object({
*/
export const zTypedEventStreamEnvelopeSessionCrashed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -4510,6 +4613,7 @@ export const zTypedEventStreamEnvelopeSessionCrashed = z.object({
*/
export const zTypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionDrainAckedWithAssignedWorkPayload,
run_id: z.string().optional(),
@@ -4527,6 +4631,7 @@ export const zTypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = z.obje
*/
export const zTypedEventStreamEnvelopeSessionDraining = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4544,6 +4649,7 @@ export const zTypedEventStreamEnvelopeSessionDraining = z.object({
*/
export const zTypedEventStreamEnvelopeSessionIdleKilled = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4561,6 +4667,7 @@ export const zTypedEventStreamEnvelopeSessionIdleKilled = z.object({
*/
export const zTypedEventStreamEnvelopeSessionMaxAgeKilled = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4578,6 +4685,7 @@ export const zTypedEventStreamEnvelopeSessionMaxAgeKilled = z.object({
*/
export const zTypedEventStreamEnvelopeSessionQuarantined = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4595,6 +4703,7 @@ export const zTypedEventStreamEnvelopeSessionQuarantined = z.object({
*/
export const zTypedEventStreamEnvelopeSessionResetStalled = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionResetStalledPayload,
run_id: z.string().optional(),
@@ -4612,6 +4721,7 @@ export const zTypedEventStreamEnvelopeSessionResetStalled = z.object({
*/
export const zTypedEventStreamEnvelopeSessionStopped = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -4629,6 +4739,7 @@ export const zTypedEventStreamEnvelopeSessionStopped = z.object({
*/
export const zTypedEventStreamEnvelopeSessionStranded = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionStrandedPayload,
run_id: z.string().optional(),
@@ -4646,6 +4757,7 @@ export const zTypedEventStreamEnvelopeSessionStranded = z.object({
*/
export const zTypedEventStreamEnvelopeSessionSuspended = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4663,6 +4775,7 @@ export const zTypedEventStreamEnvelopeSessionSuspended = z.object({
*/
export const zTypedEventStreamEnvelopeSessionUndrained = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4680,6 +4793,7 @@ export const zTypedEventStreamEnvelopeSessionUndrained = z.object({
*/
export const zTypedEventStreamEnvelopeSessionUnknownState = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionUnknownStatePayload,
run_id: z.string().optional(),
@@ -4697,6 +4811,7 @@ export const zTypedEventStreamEnvelopeSessionUnknownState = z.object({
*/
export const zTypedEventStreamEnvelopeSessionUpdated = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4714,6 +4829,7 @@ export const zTypedEventStreamEnvelopeSessionUpdated = z.object({
*/
export const zTypedEventStreamEnvelopeSessionWoke = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -4731,6 +4847,7 @@ export const zTypedEventStreamEnvelopeSessionWoke = z.object({
*/
export const zTypedEventStreamEnvelopeSessionWorkQueryFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -4748,6 +4865,7 @@ export const zTypedEventStreamEnvelopeSessionWorkQueryFailed = z.object({
*/
export const zTypedEventStreamEnvelopeStoreDegraded = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDegradedPayload,
run_id: z.string().optional(),
@@ -4765,6 +4883,7 @@ export const zTypedEventStreamEnvelopeStoreDegraded = z.object({
*/
export const zTypedEventStreamEnvelopeStoreProbeFailed = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreProbeFailedPayload,
run_id: z.string().optional(),
@@ -4782,6 +4901,7 @@ export const zTypedEventStreamEnvelopeStoreProbeFailed = z.object({
*/
export const zTypedEventStreamEnvelopeStoreRecovered = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreRecoveredPayload,
run_id: z.string().optional(),
@@ -4799,6 +4919,7 @@ export const zTypedEventStreamEnvelopeStoreRecovered = z.object({
*/
export const zTypedEventStreamEnvelopeSupervisorFsPressureSkippedTick = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorFsPressureSkippedTickPayload,
run_id: z.string().optional(),
@@ -4816,6 +4937,7 @@ export const zTypedEventStreamEnvelopeSupervisorFsPressureSkippedTick = z.object
*/
export const zTypedEventStreamEnvelopeSupervisorRequest = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorRequestPayload,
run_id: z.string().optional(),
@@ -4833,6 +4955,7 @@ export const zTypedEventStreamEnvelopeSupervisorRequest = z.object({
*/
export const zTypedEventStreamEnvelopeSupervisorShutdownRequested = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorShutdownPayload,
run_id: z.string().optional(),
@@ -4850,6 +4973,7 @@ export const zTypedEventStreamEnvelopeSupervisorShutdownRequested = z.object({
*/
export const zTypedEventStreamEnvelopeSupervisorStarted = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorStartedPayload,
run_id: z.string().optional(),
@@ -4867,6 +4991,7 @@ export const zTypedEventStreamEnvelopeSupervisorStarted = z.object({
*/
export const zTypedEventStreamEnvelopeWebhookReceived = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWebhookReceivedPayload,
run_id: z.string().optional(),
@@ -4884,6 +5009,7 @@ export const zTypedEventStreamEnvelopeWebhookReceived = z.object({
*/
export const zTypedEventStreamEnvelopeWebhookRejected = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWebhookRejectedPayload,
run_id: z.string().optional(),
@@ -4901,6 +5027,7 @@ export const zTypedEventStreamEnvelopeWebhookRejected = z.object({
*/
export const zTypedEventStreamEnvelopeWorkerOperation = z.object({
actor: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWorkerOperationEventPayload,
run_id: z.string().optional(),
@@ -4942,6 +5069,8 @@ export const zTypedEventStreamEnvelope = z.discriminatedUnion('type', [
zTypedEventStreamEnvelopeEmergencyAcked.extend({ type: z.literal('emergency.acked') }),
zTypedEventStreamEnvelopeEmergencySignaled.extend({ type: z.literal('emergency.signaled') }),
zTypedEventStreamEnvelopeEventsRotated.extend({ type: z.literal('events.rotated') }),
+ zTypedEventStreamEnvelopeExecutionStepDefined.extend({ type: z.literal('execution.step_defined') }),
+ zTypedEventStreamEnvelopeExecutionWorkAssociated.extend({ type: z.literal('execution.work_associated') }),
zTypedEventStreamEnvelopeExtmsgAdapterAdded.extend({ type: z.literal('extmsg.adapter_added') }),
zTypedEventStreamEnvelopeExtmsgAdapterRemoved.extend({ type: z.literal('extmsg.adapter_removed') }),
zTypedEventStreamEnvelopeExtmsgBound.extend({ type: z.literal('extmsg.bound') }),
@@ -5023,6 +5152,7 @@ export const zListBodyWireEvent = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadClaimRejected = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadClaimRejectedPayload,
run_id: z.string().optional(),
@@ -5041,6 +5171,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadClaimRejected = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadClosed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -5059,6 +5190,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadClosed = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadCreated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -5077,6 +5209,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadCreated = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadDeadAssigneeReopenedPayload,
run_id: z.string().optional(),
@@ -5095,6 +5228,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened = z.object(
export const zTypedTaggedEventStreamEnvelopeBeadDeleted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -5113,6 +5247,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadDeleted = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadUpdated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadEventPayload,
run_id: z.string().optional(),
@@ -5131,6 +5266,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadUpdated = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadWorktreeReapSkippedPayload,
run_id: z.string().optional(),
@@ -5149,6 +5285,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadWorktreeReaped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBeadWorktreeReapedPayload,
run_id: z.string().optional(),
@@ -5167,6 +5304,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadWorktreeReaped = z.object({
export const zTypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zConditionalWritesDegradedPayload,
run_id: z.string().optional(),
@@ -5185,6 +5323,7 @@ export const zTypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded = z.o
export const zTypedTaggedEventStreamEnvelopeBreakerStateChanged = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBreakerStateChangedPayload,
run_id: z.string().optional(),
@@ -5203,6 +5342,7 @@ export const zTypedTaggedEventStreamEnvelopeBreakerStateChanged = z.object({
export const zTypedTaggedEventStreamEnvelopeCityCreated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityLifecyclePayload,
run_id: z.string().optional(),
@@ -5221,6 +5361,7 @@ export const zTypedTaggedEventStreamEnvelopeCityCreated = z.object({
export const zTypedTaggedEventStreamEnvelopeCityResumed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5239,6 +5380,7 @@ export const zTypedTaggedEventStreamEnvelopeCityResumed = z.object({
export const zTypedTaggedEventStreamEnvelopeCitySuspended = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5257,6 +5399,7 @@ export const zTypedTaggedEventStreamEnvelopeCitySuspended = z.object({
export const zTypedTaggedEventStreamEnvelopeCityUnregisterRequested = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityLifecyclePayload,
run_id: z.string().optional(),
@@ -5275,6 +5418,7 @@ export const zTypedTaggedEventStreamEnvelopeCityUnregisterRequested = z.object({
export const zTypedTaggedEventStreamEnvelopeControllerStarted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5293,6 +5437,7 @@ export const zTypedTaggedEventStreamEnvelopeControllerStarted = z.object({
export const zTypedTaggedEventStreamEnvelopeControllerStopped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5311,6 +5456,7 @@ export const zTypedTaggedEventStreamEnvelopeControllerStopped = z.object({
export const zTypedTaggedEventStreamEnvelopeControllerTickCompleted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zControllerTickCompletedPayload,
run_id: z.string().optional(),
@@ -5329,6 +5475,7 @@ export const zTypedTaggedEventStreamEnvelopeControllerTickCompleted = z.object({
export const zTypedTaggedEventStreamEnvelopeConvoyClosed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5347,6 +5494,7 @@ export const zTypedTaggedEventStreamEnvelopeConvoyClosed = z.object({
export const zTypedTaggedEventStreamEnvelopeConvoyCreated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5365,6 +5513,7 @@ export const zTypedTaggedEventStreamEnvelopeConvoyCreated = z.object({
export const zTypedTaggedEventStreamEnvelopeCustom = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: z.unknown(),
run_id: z.string().optional(),
@@ -5383,6 +5532,7 @@ export const zTypedTaggedEventStreamEnvelopeCustom = z.object({
export const zTypedTaggedEventStreamEnvelopeDoctorAlert = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zDoctorAlertPayload,
run_id: z.string().optional(),
@@ -5401,6 +5551,7 @@ export const zTypedTaggedEventStreamEnvelopeDoctorAlert = z.object({
export const zTypedTaggedEventStreamEnvelopeEmergencyAcked = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRecord,
run_id: z.string().optional(),
@@ -5419,6 +5570,7 @@ export const zTypedTaggedEventStreamEnvelopeEmergencyAcked = z.object({
export const zTypedTaggedEventStreamEnvelopeEmergencySignaled = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRecord,
run_id: z.string().optional(),
@@ -5437,6 +5589,7 @@ export const zTypedTaggedEventStreamEnvelopeEmergencySignaled = z.object({
export const zTypedTaggedEventStreamEnvelopeEventsRotated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRotatedPayload,
run_id: z.string().optional(),
@@ -5449,12 +5602,51 @@ export const zTypedTaggedEventStreamEnvelopeEventsRotated = z.object({
workflow: zWorkflowEventProjection.optional()
});
+/**
+ * TypedTaggedEventStreamEnvelope execution.step_defined
+ */
+export const zTypedTaggedEventStreamEnvelopeExecutionStepDefined = z.object({
+ actor: z.string(),
+ city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
+ message: z.string().optional(),
+ payload: zNoPayload,
+ run_id: z.string().optional(),
+ seq: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }),
+ session_id: z.string().optional(),
+ step_id: z.string().optional(),
+ subject: z.string().optional(),
+ ts: z.iso.datetime(),
+ type: z.literal('execution.step_defined'),
+ workflow: zWorkflowEventProjection.optional()
+});
+
+/**
+ * TypedTaggedEventStreamEnvelope execution.work_associated
+ */
+export const zTypedTaggedEventStreamEnvelopeExecutionWorkAssociated = z.object({
+ actor: z.string(),
+ city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
+ message: z.string().optional(),
+ payload: zNoPayload,
+ run_id: z.string().optional(),
+ seq: z.coerce.bigint().gte(BigInt(0)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }),
+ session_id: z.string().optional(),
+ step_id: z.string().optional(),
+ subject: z.string().optional(),
+ ts: z.iso.datetime(),
+ type: z.literal('execution.work_associated'),
+ workflow: zWorkflowEventProjection.optional()
+});
+
/**
* TypedTaggedEventStreamEnvelope extmsg.adapter_added
*/
export const zTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zAdapterEventPayload,
run_id: z.string().optional(),
@@ -5473,6 +5665,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zAdapterEventPayload,
run_id: z.string().optional(),
@@ -5491,6 +5684,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgBound = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zBoundEventPayload,
run_id: z.string().optional(),
@@ -5509,6 +5703,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgBound = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgGroupCreated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zGroupCreatedEventPayload,
run_id: z.string().optional(),
@@ -5527,6 +5722,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgGroupCreated = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgInbound = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zInboundEventPayload,
run_id: z.string().optional(),
@@ -5545,6 +5741,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgInbound = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgOutbound = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOutboundEventPayload,
run_id: z.string().optional(),
@@ -5563,6 +5760,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgOutbound = z.object({
export const zTypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOutboundChannelMismatchPayload,
run_id: z.string().optional(),
@@ -5581,6 +5779,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch = z.ob
export const zTypedTaggedEventStreamEnvelopeExtmsgUnbound = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zUnboundEventPayload,
run_id: z.string().optional(),
@@ -5599,6 +5798,7 @@ export const zTypedTaggedEventStreamEnvelopeExtmsgUnbound = z.object({
export const zTypedTaggedEventStreamEnvelopeGcStoreDiskCritical = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDiskCriticalPayload,
run_id: z.string().optional(),
@@ -5617,6 +5817,7 @@ export const zTypedTaggedEventStreamEnvelopeGcStoreDiskCritical = z.object({
export const zTypedTaggedEventStreamEnvelopeGcStoreDiskWarn = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDiskWarnPayload,
run_id: z.string().optional(),
@@ -5635,6 +5836,7 @@ export const zTypedTaggedEventStreamEnvelopeGcStoreDiskWarn = z.object({
export const zTypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreMaintenanceDonePayload,
run_id: z.string().optional(),
@@ -5653,6 +5855,7 @@ export const zTypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone = z.object({
export const zTypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreMaintenanceFailedPayload,
run_id: z.string().optional(),
@@ -5671,6 +5874,7 @@ export const zTypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed = z.object(
export const zTypedTaggedEventStreamEnvelopeMailArchived = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5689,6 +5893,7 @@ export const zTypedTaggedEventStreamEnvelopeMailArchived = z.object({
export const zTypedTaggedEventStreamEnvelopeMailDeleted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5707,6 +5912,7 @@ export const zTypedTaggedEventStreamEnvelopeMailDeleted = z.object({
export const zTypedTaggedEventStreamEnvelopeMailMarkedRead = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5725,6 +5931,7 @@ export const zTypedTaggedEventStreamEnvelopeMailMarkedRead = z.object({
export const zTypedTaggedEventStreamEnvelopeMailMarkedUnread = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5743,6 +5950,7 @@ export const zTypedTaggedEventStreamEnvelopeMailMarkedUnread = z.object({
export const zTypedTaggedEventStreamEnvelopeMailRead = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5761,6 +5969,7 @@ export const zTypedTaggedEventStreamEnvelopeMailRead = z.object({
export const zTypedTaggedEventStreamEnvelopeMailReplied = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5779,6 +5988,7 @@ export const zTypedTaggedEventStreamEnvelopeMailReplied = z.object({
export const zTypedTaggedEventStreamEnvelopeMailSent = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMailEventPayload,
run_id: z.string().optional(),
@@ -5797,6 +6007,7 @@ export const zTypedTaggedEventStreamEnvelopeMailSent = z.object({
export const zTypedTaggedEventStreamEnvelopeMoleculeResolved = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zMoleculeResolvedPayload,
run_id: z.string().optional(),
@@ -5815,6 +6026,7 @@ export const zTypedTaggedEventStreamEnvelopeMoleculeResolved = z.object({
export const zTypedTaggedEventStreamEnvelopeOrderCompleted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5833,6 +6045,7 @@ export const zTypedTaggedEventStreamEnvelopeOrderCompleted = z.object({
export const zTypedTaggedEventStreamEnvelopeOrderFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5851,6 +6064,7 @@ export const zTypedTaggedEventStreamEnvelopeOrderFailed = z.object({
export const zTypedTaggedEventStreamEnvelopeOrderFired = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5869,6 +6083,7 @@ export const zTypedTaggedEventStreamEnvelopeOrderFired = z.object({
export const zTypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zOrderGateTimeoutFailOpenPayload,
run_id: z.string().optional(),
@@ -5887,6 +6102,7 @@ export const zTypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen = z.object(
export const zTypedTaggedEventStreamEnvelopePgCredentialResolved = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zPostgresCredentialResolvedPayload,
run_id: z.string().optional(),
@@ -5905,6 +6121,7 @@ export const zTypedTaggedEventStreamEnvelopePgCredentialResolved = z.object({
export const zTypedTaggedEventStreamEnvelopeProjectIdentityStamped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zProjectIdentityStampedPayload,
run_id: z.string().optional(),
@@ -5923,6 +6140,7 @@ export const zTypedTaggedEventStreamEnvelopeProjectIdentityStamped = z.object({
export const zTypedTaggedEventStreamEnvelopeProviderQuotaObserved = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zQuotaObservedPayload,
run_id: z.string().optional(),
@@ -5941,6 +6159,7 @@ export const zTypedTaggedEventStreamEnvelopeProviderQuotaObserved = z.object({
export const zTypedTaggedEventStreamEnvelopeProviderQuotaPollFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zQuotaPollFailedPayload,
run_id: z.string().optional(),
@@ -5959,6 +6178,7 @@ export const zTypedTaggedEventStreamEnvelopeProviderQuotaPollFailed = z.object({
export const zTypedTaggedEventStreamEnvelopeProviderSwapped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -5977,6 +6197,7 @@ export const zTypedTaggedEventStreamEnvelopeProviderSwapped = z.object({
export const zTypedTaggedEventStreamEnvelopeProxyReaped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zProxyReapedPayload,
run_id: z.string().optional(),
@@ -5995,6 +6216,7 @@ export const zTypedTaggedEventStreamEnvelopeProxyReaped = z.object({
export const zTypedTaggedEventStreamEnvelopeRequestFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRequestFailedPayload,
run_id: z.string().optional(),
@@ -6013,6 +6235,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestFailed = z.object({
export const zTypedTaggedEventStreamEnvelopeRequestResultCityCreate = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityCreateSucceededPayload,
run_id: z.string().optional(),
@@ -6031,6 +6254,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultCityCreate = z.object({
export const zTypedTaggedEventStreamEnvelopeRequestResultCityUnregister = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zCityUnregisterSucceededPayload,
run_id: z.string().optional(),
@@ -6049,6 +6273,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultCityUnregister = z.obje
export const zTypedTaggedEventStreamEnvelopeRequestResultRigCreate = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRigCreateSucceededPayload,
run_id: z.string().optional(),
@@ -6067,6 +6292,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultRigCreate = z.object({
export const zTypedTaggedEventStreamEnvelopeRequestResultSessionCreate = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionCreateSucceededPayload,
run_id: z.string().optional(),
@@ -6085,6 +6311,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultSessionCreate = z.objec
export const zTypedTaggedEventStreamEnvelopeRequestResultSessionMessage = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionMessageSucceededPayload,
run_id: z.string().optional(),
@@ -6103,6 +6330,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultSessionMessage = z.obje
export const zTypedTaggedEventStreamEnvelopeRequestResultSessionSubmit = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionSubmitSucceededPayload,
run_id: z.string().optional(),
@@ -6121,6 +6349,7 @@ export const zTypedTaggedEventStreamEnvelopeRequestResultSessionSubmit = z.objec
export const zTypedTaggedEventStreamEnvelopeRigProvisionProgress = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zRigProvisionProgressPayload,
run_id: z.string().optional(),
@@ -6139,6 +6368,7 @@ export const zTypedTaggedEventStreamEnvelopeRigProvisionProgress = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionColdStartTimeout = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6157,6 +6387,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionColdStartTimeout = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionCrashed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -6175,6 +6406,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionCrashed = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionDrainAckedWithAssignedWorkPayload,
run_id: z.string().optional(),
@@ -6193,6 +6425,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork =
export const zTypedTaggedEventStreamEnvelopeSessionDraining = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6211,6 +6444,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionDraining = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionIdleKilled = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6229,6 +6463,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionIdleKilled = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionMaxAgeKilled = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6247,6 +6482,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionMaxAgeKilled = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionQuarantined = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6265,6 +6501,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionQuarantined = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionResetStalled = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionResetStalledPayload,
run_id: z.string().optional(),
@@ -6283,6 +6520,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionResetStalled = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionStopped = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -6301,6 +6539,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionStopped = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionStranded = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionStrandedPayload,
run_id: z.string().optional(),
@@ -6319,6 +6558,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionStranded = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionSuspended = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6337,6 +6577,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionSuspended = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionUndrained = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6355,6 +6596,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionUndrained = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionUnknownState = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionUnknownStatePayload,
run_id: z.string().optional(),
@@ -6373,6 +6615,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionUnknownState = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionUpdated = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6391,6 +6634,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionUpdated = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionWoke = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zNoPayload,
run_id: z.string().optional(),
@@ -6409,6 +6653,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionWoke = z.object({
export const zTypedTaggedEventStreamEnvelopeSessionWorkQueryFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSessionLifecyclePayload,
run_id: z.string().optional(),
@@ -6427,6 +6672,7 @@ export const zTypedTaggedEventStreamEnvelopeSessionWorkQueryFailed = z.object({
export const zTypedTaggedEventStreamEnvelopeStoreDegraded = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreDegradedPayload,
run_id: z.string().optional(),
@@ -6445,6 +6691,7 @@ export const zTypedTaggedEventStreamEnvelopeStoreDegraded = z.object({
export const zTypedTaggedEventStreamEnvelopeStoreProbeFailed = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreProbeFailedPayload,
run_id: z.string().optional(),
@@ -6463,6 +6710,7 @@ export const zTypedTaggedEventStreamEnvelopeStoreProbeFailed = z.object({
export const zTypedTaggedEventStreamEnvelopeStoreRecovered = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zStoreRecoveredPayload,
run_id: z.string().optional(),
@@ -6481,6 +6729,7 @@ export const zTypedTaggedEventStreamEnvelopeStoreRecovered = z.object({
export const zTypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorFsPressureSkippedTickPayload,
run_id: z.string().optional(),
@@ -6499,6 +6748,7 @@ export const zTypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick = z.
export const zTypedTaggedEventStreamEnvelopeSupervisorRequest = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorRequestPayload,
run_id: z.string().optional(),
@@ -6517,6 +6767,7 @@ export const zTypedTaggedEventStreamEnvelopeSupervisorRequest = z.object({
export const zTypedTaggedEventStreamEnvelopeSupervisorShutdownRequested = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorShutdownPayload,
run_id: z.string().optional(),
@@ -6535,6 +6786,7 @@ export const zTypedTaggedEventStreamEnvelopeSupervisorShutdownRequested = z.obje
export const zTypedTaggedEventStreamEnvelopeSupervisorStarted = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zSupervisorStartedPayload,
run_id: z.string().optional(),
@@ -6553,6 +6805,7 @@ export const zTypedTaggedEventStreamEnvelopeSupervisorStarted = z.object({
export const zTypedTaggedEventStreamEnvelopeWebhookReceived = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWebhookReceivedPayload,
run_id: z.string().optional(),
@@ -6571,6 +6824,7 @@ export const zTypedTaggedEventStreamEnvelopeWebhookReceived = z.object({
export const zTypedTaggedEventStreamEnvelopeWebhookRejected = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWebhookRejectedPayload,
run_id: z.string().optional(),
@@ -6589,6 +6843,7 @@ export const zTypedTaggedEventStreamEnvelopeWebhookRejected = z.object({
export const zTypedTaggedEventStreamEnvelopeWorkerOperation = z.object({
actor: z.string(),
city: z.string(),
+ depends_on_step_ids: z.array(z.string()).optional(),
message: z.string().optional(),
payload: zWorkerOperationEventPayload,
run_id: z.string().optional(),
@@ -6630,6 +6885,8 @@ export const zTypedTaggedEventStreamEnvelope = z.discriminatedUnion('type', [
zTypedTaggedEventStreamEnvelopeEmergencyAcked.extend({ type: z.literal('emergency.acked') }),
zTypedTaggedEventStreamEnvelopeEmergencySignaled.extend({ type: z.literal('emergency.signaled') }),
zTypedTaggedEventStreamEnvelopeEventsRotated.extend({ type: z.literal('events.rotated') }),
+ zTypedTaggedEventStreamEnvelopeExecutionStepDefined.extend({ type: z.literal('execution.step_defined') }),
+ zTypedTaggedEventStreamEnvelopeExecutionWorkAssociated.extend({ type: z.literal('execution.work_associated') }),
zTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded.extend({ type: z.literal('extmsg.adapter_added') }),
zTypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved.extend({ type: z.literal('extmsg.adapter_removed') }),
zTypedTaggedEventStreamEnvelopeExtmsgBound.extend({ type: z.literal('extmsg.bound') }),
diff --git a/internal/api/dashboardspa/web/shared/src/run-detail.ts b/internal/api/dashboardspa/web/shared/src/run-detail.ts
index 38fa927677..6ed495e891 100644
--- a/internal/api/dashboardspa/web/shared/src/run-detail.ts
+++ b/internal/api/dashboardspa/web/shared/src/run-detail.ts
@@ -47,8 +47,20 @@ export type RunIteration = { kind: 'base' } | { kind: 'loop'; value: number };
export type RunAttempt = { kind: 'untracked' } | { kind: 'attempt'; value: number };
+/**
+ * Per-instance session attachment. On the `attached` arm `link` and
+ * `streamable` are optional because the read-only public projection (the
+ * "public floor") redacts them: when a session id can't be exposed it emits
+ * `{ kind: 'attached' }` with no link at all. The in-repo Go marshaler
+ * (runproj.sessionState) always emits both fields and never produces a
+ * link-less `attached`, so this optionality models the external redacted shape
+ * only — but the shared contract must express it so every consumer is forced to
+ * guard the absent-link case production already produces, instead of compiling
+ * an unsafe `attached.link` dereference that reintroduces the render crash. See
+ * SessionTranscript in RunNodeSessionPanel for the guard.
+ */
export type RunSessionAttachment =
- | { kind: 'attached'; link: RunSessionLink; streamable: boolean }
+ | { kind: 'attached'; link?: RunSessionLink; streamable?: boolean }
| { kind: 'none'; reason: 'not_started' | 'session_unresolved' };
export interface RunExecutionInstance {
diff --git a/internal/api/event_envelope_schemas.go b/internal/api/event_envelope_schemas.go
index ddb7d47a85..75a360b409 100644
--- a/internal/api/event_envelope_schemas.go
+++ b/internal/api/event_envelope_schemas.go
@@ -140,8 +140,9 @@ func typedEventEnvelopeVariantSchema(r huma.Registry, variant typedEventEnvelope
"step_id": {
Type: huma.TypeString,
},
- "workflow": r.Schema(reflect.TypeOf(workflowEventProjection{}), true, "WorkflowEventProjection"),
- "payload": r.Schema(variant.payloadType, true, variant.payloadType.Name()),
+ "depends_on_step_ids": eventEnvelopeTopologyProperty(),
+ "workflow": r.Schema(reflect.TypeOf(workflowEventProjection{}), true, "WorkflowEventProjection"),
+ "payload": r.Schema(variant.payloadType, true, variant.payloadType.Name()),
}
required := []string{"seq", "type", "ts", "actor", "payload"}
if cfg.includeCity {
@@ -194,8 +195,9 @@ func customEventEnvelopeVariantSchema(r huma.Registry, cfg typedEventEnvelopeSch
"step_id": {
Type: huma.TypeString,
},
- "workflow": r.Schema(reflect.TypeOf(workflowEventProjection{}), true, "WorkflowEventProjection"),
- "payload": {},
+ "depends_on_step_ids": eventEnvelopeTopologyProperty(),
+ "workflow": r.Schema(reflect.TypeOf(workflowEventProjection{}), true, "WorkflowEventProjection"),
+ "payload": {},
}
required := []string{"seq", "type", "ts", "actor", "payload"}
if cfg.includeCity {
@@ -211,6 +213,13 @@ func customEventEnvelopeVariantSchema(r huma.Registry, cfg typedEventEnvelopeSch
}
}
+func eventEnvelopeTopologyProperty() *huma.Schema {
+ return &huma.Schema{
+ Type: huma.TypeArray,
+ Items: &huma.Schema{Type: huma.TypeString},
+ }
+}
+
func eventTypeSchemaSuffix(eventType string) string {
parts := strings.FieldsFunc(eventType, func(r rune) bool {
return r == '.' || r == '_' || r == '-'
diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go
index f116e09d03..85583358e7 100644
--- a/internal/api/genclient/client_gen.go
+++ b/internal/api/genclient/client_gen.go
@@ -946,6 +946,7 @@ type AgentOutputResponse struct {
type AgentPatch struct {
AppendFragments *[]string `json:"AppendFragments"`
Args *[]string `json:"Args"`
+ AssignedWorkDeferLimit *int64 `json:"AssignedWorkDeferLimit"`
Attach *bool `json:"Attach"`
DefaultSlingFormula *string `json:"DefaultSlingFormula"`
DependsOn *[]string `json:"DependsOn"`
@@ -1742,17 +1743,18 @@ type EventRotateResponse struct {
// EventStreamEnvelope defines model for EventStreamEnvelope.
type EventStreamEnvelope struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload *EventPayload `json:"payload,omitempty"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload *EventPayload `json:"payload,omitempty"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// ExtMsgAdapterRegisterInputBody defines model for ExtMsgAdapterRegisterInputBody.
@@ -5287,18 +5289,19 @@ type SupervisorStartup struct {
// TaggedEventStreamEnvelope defines model for TaggedEventStreamEnvelope.
type TaggedEventStreamEnvelope struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload *EventPayload `json:"payload,omitempty"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload *EventPayload `json:"payload,omitempty"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TranscriptMessageKind Direction of a transcript entry.
@@ -5314,1322 +5317,1442 @@ type TypedEventStreamEnvelope struct {
// TypedEventStreamEnvelopeBeadClaimRejected defines model for TypedEventStreamEnvelopeBeadClaimRejected.
type TypedEventStreamEnvelopeBeadClaimRejected struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadClaimRejectedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadClaimRejectedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadClosed defines model for TypedEventStreamEnvelopeBeadClosed.
type TypedEventStreamEnvelopeBeadClosed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadCreated defines model for TypedEventStreamEnvelopeBeadCreated.
type TypedEventStreamEnvelopeBeadCreated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadDeadAssigneeReopened defines model for TypedEventStreamEnvelopeBeadDeadAssigneeReopened.
type TypedEventStreamEnvelopeBeadDeadAssigneeReopened struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadDeadAssigneeReopenedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadDeadAssigneeReopenedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadDeleted defines model for TypedEventStreamEnvelopeBeadDeleted.
type TypedEventStreamEnvelopeBeadDeleted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadUpdated defines model for TypedEventStreamEnvelopeBeadUpdated.
type TypedEventStreamEnvelopeBeadUpdated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadWorktreeReapSkipped defines model for TypedEventStreamEnvelopeBeadWorktreeReapSkipped.
type TypedEventStreamEnvelopeBeadWorktreeReapSkipped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadWorktreeReapSkippedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadWorktreeReapSkippedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadWorktreeReaped defines model for TypedEventStreamEnvelopeBeadWorktreeReaped.
type TypedEventStreamEnvelopeBeadWorktreeReaped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BeadWorktreeReapedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadWorktreeReapedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBeadsConditionalWritesDegraded defines model for TypedEventStreamEnvelopeBeadsConditionalWritesDegraded.
type TypedEventStreamEnvelopeBeadsConditionalWritesDegraded struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload ConditionalWritesDegradedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ConditionalWritesDegradedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeBreakerStateChanged defines model for TypedEventStreamEnvelopeBreakerStateChanged.
type TypedEventStreamEnvelopeBreakerStateChanged struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BreakerStateChangedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BreakerStateChangedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeCityCreated defines model for TypedEventStreamEnvelopeCityCreated.
type TypedEventStreamEnvelopeCityCreated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload CityLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeCityResumed defines model for TypedEventStreamEnvelopeCityResumed.
type TypedEventStreamEnvelopeCityResumed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeCitySuspended defines model for TypedEventStreamEnvelopeCitySuspended.
type TypedEventStreamEnvelopeCitySuspended struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeCityUnregisterRequested defines model for TypedEventStreamEnvelopeCityUnregisterRequested.
type TypedEventStreamEnvelopeCityUnregisterRequested struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload CityLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeControllerStarted defines model for TypedEventStreamEnvelopeControllerStarted.
type TypedEventStreamEnvelopeControllerStarted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeControllerStopped defines model for TypedEventStreamEnvelopeControllerStopped.
type TypedEventStreamEnvelopeControllerStopped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeControllerTickCompleted defines model for TypedEventStreamEnvelopeControllerTickCompleted.
type TypedEventStreamEnvelopeControllerTickCompleted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload ControllerTickCompletedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ControllerTickCompletedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeConvoyClosed defines model for TypedEventStreamEnvelopeConvoyClosed.
type TypedEventStreamEnvelopeConvoyClosed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeConvoyCreated defines model for TypedEventStreamEnvelopeConvoyCreated.
type TypedEventStreamEnvelopeConvoyCreated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeCustom defines model for TypedEventStreamEnvelopeCustom.
type TypedEventStreamEnvelopeCustom struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload interface{} `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload interface{} `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeDoctorAlert defines model for TypedEventStreamEnvelopeDoctorAlert.
type TypedEventStreamEnvelopeDoctorAlert struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload DoctorAlertPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload DoctorAlertPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeEmergencyAcked defines model for TypedEventStreamEnvelopeEmergencyAcked.
type TypedEventStreamEnvelopeEmergencyAcked struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload Record `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload Record `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeEmergencySignaled defines model for TypedEventStreamEnvelopeEmergencySignaled.
type TypedEventStreamEnvelopeEmergencySignaled struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload Record `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload Record `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeEventsRotated defines model for TypedEventStreamEnvelopeEventsRotated.
type TypedEventStreamEnvelopeEventsRotated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload RotatedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RotatedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+}
+
+// TypedEventStreamEnvelopeExecutionStepDefined defines model for TypedEventStreamEnvelopeExecutionStepDefined.
+type TypedEventStreamEnvelopeExecutionStepDefined struct {
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+}
+
+// TypedEventStreamEnvelopeExecutionWorkAssociated defines model for TypedEventStreamEnvelopeExecutionWorkAssociated.
+type TypedEventStreamEnvelopeExecutionWorkAssociated struct {
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgAdapterAdded defines model for TypedEventStreamEnvelopeExtmsgAdapterAdded.
type TypedEventStreamEnvelopeExtmsgAdapterAdded struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload AdapterEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload AdapterEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgAdapterRemoved defines model for TypedEventStreamEnvelopeExtmsgAdapterRemoved.
type TypedEventStreamEnvelopeExtmsgAdapterRemoved struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload AdapterEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload AdapterEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgBound defines model for TypedEventStreamEnvelopeExtmsgBound.
type TypedEventStreamEnvelopeExtmsgBound struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload BoundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BoundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgGroupCreated defines model for TypedEventStreamEnvelopeExtmsgGroupCreated.
type TypedEventStreamEnvelopeExtmsgGroupCreated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload GroupCreatedEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload GroupCreatedEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgInbound defines model for TypedEventStreamEnvelopeExtmsgInbound.
type TypedEventStreamEnvelopeExtmsgInbound struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload InboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload InboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgOutbound defines model for TypedEventStreamEnvelopeExtmsgOutbound.
type TypedEventStreamEnvelopeExtmsgOutbound struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload OutboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OutboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch defines model for TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch.
type TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload OutboundChannelMismatchPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OutboundChannelMismatchPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeExtmsgUnbound defines model for TypedEventStreamEnvelopeExtmsgUnbound.
type TypedEventStreamEnvelopeExtmsgUnbound struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload UnboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload UnboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeGcStoreDiskCritical defines model for TypedEventStreamEnvelopeGcStoreDiskCritical.
type TypedEventStreamEnvelopeGcStoreDiskCritical struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreDiskCriticalPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDiskCriticalPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeGcStoreDiskWarn defines model for TypedEventStreamEnvelopeGcStoreDiskWarn.
type TypedEventStreamEnvelopeGcStoreDiskWarn struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreDiskWarnPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDiskWarnPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeGcStoreMaintenanceDone defines model for TypedEventStreamEnvelopeGcStoreMaintenanceDone.
type TypedEventStreamEnvelopeGcStoreMaintenanceDone struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreMaintenanceDonePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreMaintenanceDonePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeGcStoreMaintenanceFailed defines model for TypedEventStreamEnvelopeGcStoreMaintenanceFailed.
type TypedEventStreamEnvelopeGcStoreMaintenanceFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreMaintenanceFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreMaintenanceFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailArchived defines model for TypedEventStreamEnvelopeMailArchived.
type TypedEventStreamEnvelopeMailArchived struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailDeleted defines model for TypedEventStreamEnvelopeMailDeleted.
type TypedEventStreamEnvelopeMailDeleted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailMarkedRead defines model for TypedEventStreamEnvelopeMailMarkedRead.
type TypedEventStreamEnvelopeMailMarkedRead struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailMarkedUnread defines model for TypedEventStreamEnvelopeMailMarkedUnread.
type TypedEventStreamEnvelopeMailMarkedUnread struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailRead defines model for TypedEventStreamEnvelopeMailRead.
type TypedEventStreamEnvelopeMailRead struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailReplied defines model for TypedEventStreamEnvelopeMailReplied.
type TypedEventStreamEnvelopeMailReplied struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMailSent defines model for TypedEventStreamEnvelopeMailSent.
type TypedEventStreamEnvelopeMailSent struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeMoleculeResolved defines model for TypedEventStreamEnvelopeMoleculeResolved.
type TypedEventStreamEnvelopeMoleculeResolved struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload MoleculeResolvedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MoleculeResolvedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeOrderCompleted defines model for TypedEventStreamEnvelopeOrderCompleted.
type TypedEventStreamEnvelopeOrderCompleted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeOrderFailed defines model for TypedEventStreamEnvelopeOrderFailed.
type TypedEventStreamEnvelopeOrderFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeOrderFired defines model for TypedEventStreamEnvelopeOrderFired.
type TypedEventStreamEnvelopeOrderFired struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeOrderGateTimeoutFailOpen defines model for TypedEventStreamEnvelopeOrderGateTimeoutFailOpen.
type TypedEventStreamEnvelopeOrderGateTimeoutFailOpen struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload OrderGateTimeoutFailOpenPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OrderGateTimeoutFailOpenPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopePgCredentialResolved defines model for TypedEventStreamEnvelopePgCredentialResolved.
type TypedEventStreamEnvelopePgCredentialResolved struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload PostgresCredentialResolvedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload PostgresCredentialResolvedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeProjectIdentityStamped defines model for TypedEventStreamEnvelopeProjectIdentityStamped.
type TypedEventStreamEnvelopeProjectIdentityStamped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload ProjectIdentityStampedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ProjectIdentityStampedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeProviderQuotaObserved defines model for TypedEventStreamEnvelopeProviderQuotaObserved.
type TypedEventStreamEnvelopeProviderQuotaObserved struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload QuotaObservedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload QuotaObservedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeProviderQuotaPollFailed defines model for TypedEventStreamEnvelopeProviderQuotaPollFailed.
type TypedEventStreamEnvelopeProviderQuotaPollFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload QuotaPollFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload QuotaPollFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeProviderSwapped defines model for TypedEventStreamEnvelopeProviderSwapped.
type TypedEventStreamEnvelopeProviderSwapped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeProxyReaped defines model for TypedEventStreamEnvelopeProxyReaped.
type TypedEventStreamEnvelopeProxyReaped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload ProxyReapedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ProxyReapedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestFailed defines model for TypedEventStreamEnvelopeRequestFailed.
type TypedEventStreamEnvelopeRequestFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload RequestFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RequestFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultCityCreate defines model for TypedEventStreamEnvelopeRequestResultCityCreate.
type TypedEventStreamEnvelopeRequestResultCityCreate struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload CityCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultCityUnregister defines model for TypedEventStreamEnvelopeRequestResultCityUnregister.
type TypedEventStreamEnvelopeRequestResultCityUnregister struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload CityUnregisterSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityUnregisterSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultRigCreate defines model for TypedEventStreamEnvelopeRequestResultRigCreate.
type TypedEventStreamEnvelopeRequestResultRigCreate struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload RigCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RigCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultSessionCreate defines model for TypedEventStreamEnvelopeRequestResultSessionCreate.
type TypedEventStreamEnvelopeRequestResultSessionCreate struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultSessionMessage defines model for TypedEventStreamEnvelopeRequestResultSessionMessage.
type TypedEventStreamEnvelopeRequestResultSessionMessage struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionMessageSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionMessageSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRequestResultSessionSubmit defines model for TypedEventStreamEnvelopeRequestResultSessionSubmit.
type TypedEventStreamEnvelopeRequestResultSessionSubmit struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionSubmitSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionSubmitSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeRigProvisionProgress defines model for TypedEventStreamEnvelopeRigProvisionProgress.
type TypedEventStreamEnvelopeRigProvisionProgress struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload RigProvisionProgressPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RigProvisionProgressPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionColdStartTimeout defines model for TypedEventStreamEnvelopeSessionColdStartTimeout.
type TypedEventStreamEnvelopeSessionColdStartTimeout struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionCrashed defines model for TypedEventStreamEnvelopeSessionCrashed.
type TypedEventStreamEnvelopeSessionCrashed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork defines model for TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork.
type TypedEventStreamEnvelopeSessionDrainAckedWithAssignedWork struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionDrainAckedWithAssignedWorkPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionDrainAckedWithAssignedWorkPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionDraining defines model for TypedEventStreamEnvelopeSessionDraining.
type TypedEventStreamEnvelopeSessionDraining struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionIdleKilled defines model for TypedEventStreamEnvelopeSessionIdleKilled.
type TypedEventStreamEnvelopeSessionIdleKilled struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionMaxAgeKilled defines model for TypedEventStreamEnvelopeSessionMaxAgeKilled.
type TypedEventStreamEnvelopeSessionMaxAgeKilled struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionQuarantined defines model for TypedEventStreamEnvelopeSessionQuarantined.
type TypedEventStreamEnvelopeSessionQuarantined struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionResetStalled defines model for TypedEventStreamEnvelopeSessionResetStalled.
type TypedEventStreamEnvelopeSessionResetStalled struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionResetStalledPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionResetStalledPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionStopped defines model for TypedEventStreamEnvelopeSessionStopped.
type TypedEventStreamEnvelopeSessionStopped struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionStranded defines model for TypedEventStreamEnvelopeSessionStranded.
type TypedEventStreamEnvelopeSessionStranded struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionStrandedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionStrandedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionSuspended defines model for TypedEventStreamEnvelopeSessionSuspended.
type TypedEventStreamEnvelopeSessionSuspended struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionUndrained defines model for TypedEventStreamEnvelopeSessionUndrained.
type TypedEventStreamEnvelopeSessionUndrained struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionUnknownState defines model for TypedEventStreamEnvelopeSessionUnknownState.
type TypedEventStreamEnvelopeSessionUnknownState struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionUnknownStatePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionUnknownStatePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionUpdated defines model for TypedEventStreamEnvelopeSessionUpdated.
type TypedEventStreamEnvelopeSessionUpdated struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionWoke defines model for TypedEventStreamEnvelopeSessionWoke.
type TypedEventStreamEnvelopeSessionWoke struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSessionWorkQueryFailed defines model for TypedEventStreamEnvelopeSessionWorkQueryFailed.
type TypedEventStreamEnvelopeSessionWorkQueryFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeStoreDegraded defines model for TypedEventStreamEnvelopeStoreDegraded.
type TypedEventStreamEnvelopeStoreDegraded struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreDegradedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDegradedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeStoreProbeFailed defines model for TypedEventStreamEnvelopeStoreProbeFailed.
type TypedEventStreamEnvelopeStoreProbeFailed struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreProbeFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreProbeFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeStoreRecovered defines model for TypedEventStreamEnvelopeStoreRecovered.
type TypedEventStreamEnvelopeStoreRecovered struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload StoreRecoveredPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreRecoveredPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick defines model for TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick.
type TypedEventStreamEnvelopeSupervisorFsPressureSkippedTick struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorFSPressureSkippedTickPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorFSPressureSkippedTickPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSupervisorRequest defines model for TypedEventStreamEnvelopeSupervisorRequest.
type TypedEventStreamEnvelopeSupervisorRequest struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorRequestPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorRequestPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSupervisorShutdownRequested defines model for TypedEventStreamEnvelopeSupervisorShutdownRequested.
type TypedEventStreamEnvelopeSupervisorShutdownRequested struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorShutdownPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorShutdownPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeSupervisorStarted defines model for TypedEventStreamEnvelopeSupervisorStarted.
type TypedEventStreamEnvelopeSupervisorStarted struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorStartedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorStartedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeWebhookReceived defines model for TypedEventStreamEnvelopeWebhookReceived.
type TypedEventStreamEnvelopeWebhookReceived struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload WebhookReceivedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WebhookReceivedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeWebhookRejected defines model for TypedEventStreamEnvelopeWebhookRejected.
type TypedEventStreamEnvelopeWebhookRejected struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload WebhookRejectedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WebhookRejectedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedEventStreamEnvelopeWorkerOperation defines model for TypedEventStreamEnvelopeWorkerOperation.
type TypedEventStreamEnvelopeWorkerOperation struct {
- Actor string `json:"actor"`
- Message *string `json:"message,omitempty"`
- Payload WorkerOperationEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WorkerOperationEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelope Discriminated union of supervisor event stream envelopes. Each variant constrains the envelope type and payload schema together and includes the source city.
@@ -6639,1410 +6762,1532 @@ type TypedTaggedEventStreamEnvelope struct {
// TypedTaggedEventStreamEnvelopeBeadClaimRejected defines model for TypedTaggedEventStreamEnvelopeBeadClaimRejected.
type TypedTaggedEventStreamEnvelopeBeadClaimRejected struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadClaimRejectedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadClaimRejectedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadClosed defines model for TypedTaggedEventStreamEnvelopeBeadClosed.
type TypedTaggedEventStreamEnvelopeBeadClosed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadCreated defines model for TypedTaggedEventStreamEnvelopeBeadCreated.
type TypedTaggedEventStreamEnvelopeBeadCreated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened defines model for TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened.
type TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadDeadAssigneeReopenedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadDeadAssigneeReopenedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadDeleted defines model for TypedTaggedEventStreamEnvelopeBeadDeleted.
type TypedTaggedEventStreamEnvelopeBeadDeleted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadUpdated defines model for TypedTaggedEventStreamEnvelopeBeadUpdated.
type TypedTaggedEventStreamEnvelopeBeadUpdated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped defines model for TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped.
type TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadWorktreeReapSkippedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadWorktreeReapSkippedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadWorktreeReaped defines model for TypedTaggedEventStreamEnvelopeBeadWorktreeReaped.
type TypedTaggedEventStreamEnvelopeBeadWorktreeReaped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BeadWorktreeReapedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BeadWorktreeReapedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded defines model for TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded.
type TypedTaggedEventStreamEnvelopeBeadsConditionalWritesDegraded struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload ConditionalWritesDegradedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ConditionalWritesDegradedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeBreakerStateChanged defines model for TypedTaggedEventStreamEnvelopeBreakerStateChanged.
type TypedTaggedEventStreamEnvelopeBreakerStateChanged struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BreakerStateChangedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BreakerStateChangedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeCityCreated defines model for TypedTaggedEventStreamEnvelopeCityCreated.
type TypedTaggedEventStreamEnvelopeCityCreated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload CityLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeCityResumed defines model for TypedTaggedEventStreamEnvelopeCityResumed.
type TypedTaggedEventStreamEnvelopeCityResumed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeCitySuspended defines model for TypedTaggedEventStreamEnvelopeCitySuspended.
type TypedTaggedEventStreamEnvelopeCitySuspended struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeCityUnregisterRequested defines model for TypedTaggedEventStreamEnvelopeCityUnregisterRequested.
type TypedTaggedEventStreamEnvelopeCityUnregisterRequested struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload CityLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeControllerStarted defines model for TypedTaggedEventStreamEnvelopeControllerStarted.
type TypedTaggedEventStreamEnvelopeControllerStarted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeControllerStopped defines model for TypedTaggedEventStreamEnvelopeControllerStopped.
type TypedTaggedEventStreamEnvelopeControllerStopped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeControllerTickCompleted defines model for TypedTaggedEventStreamEnvelopeControllerTickCompleted.
type TypedTaggedEventStreamEnvelopeControllerTickCompleted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload ControllerTickCompletedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ControllerTickCompletedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeConvoyClosed defines model for TypedTaggedEventStreamEnvelopeConvoyClosed.
type TypedTaggedEventStreamEnvelopeConvoyClosed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeConvoyCreated defines model for TypedTaggedEventStreamEnvelopeConvoyCreated.
type TypedTaggedEventStreamEnvelopeConvoyCreated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeCustom defines model for TypedTaggedEventStreamEnvelopeCustom.
type TypedTaggedEventStreamEnvelopeCustom struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload interface{} `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload interface{} `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeDoctorAlert defines model for TypedTaggedEventStreamEnvelopeDoctorAlert.
type TypedTaggedEventStreamEnvelopeDoctorAlert struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload DoctorAlertPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload DoctorAlertPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeEmergencyAcked defines model for TypedTaggedEventStreamEnvelopeEmergencyAcked.
type TypedTaggedEventStreamEnvelopeEmergencyAcked struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload Record `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload Record `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeEmergencySignaled defines model for TypedTaggedEventStreamEnvelopeEmergencySignaled.
type TypedTaggedEventStreamEnvelopeEmergencySignaled struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload Record `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload Record `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeEventsRotated defines model for TypedTaggedEventStreamEnvelopeEventsRotated.
type TypedTaggedEventStreamEnvelopeEventsRotated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload RotatedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RotatedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+}
+
+// TypedTaggedEventStreamEnvelopeExecutionStepDefined defines model for TypedTaggedEventStreamEnvelopeExecutionStepDefined.
+type TypedTaggedEventStreamEnvelopeExecutionStepDefined struct {
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+}
+
+// TypedTaggedEventStreamEnvelopeExecutionWorkAssociated defines model for TypedTaggedEventStreamEnvelopeExecutionWorkAssociated.
+type TypedTaggedEventStreamEnvelopeExecutionWorkAssociated struct {
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded defines model for TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded.
type TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload AdapterEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload AdapterEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved defines model for TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved.
type TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload AdapterEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload AdapterEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgBound defines model for TypedTaggedEventStreamEnvelopeExtmsgBound.
type TypedTaggedEventStreamEnvelopeExtmsgBound struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload BoundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload BoundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgGroupCreated defines model for TypedTaggedEventStreamEnvelopeExtmsgGroupCreated.
type TypedTaggedEventStreamEnvelopeExtmsgGroupCreated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload GroupCreatedEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload GroupCreatedEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgInbound defines model for TypedTaggedEventStreamEnvelopeExtmsgInbound.
type TypedTaggedEventStreamEnvelopeExtmsgInbound struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload InboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload InboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgOutbound defines model for TypedTaggedEventStreamEnvelopeExtmsgOutbound.
type TypedTaggedEventStreamEnvelopeExtmsgOutbound struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload OutboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OutboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch defines model for TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch.
type TypedTaggedEventStreamEnvelopeExtmsgOutboundChannelMismatch struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload OutboundChannelMismatchPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OutboundChannelMismatchPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeExtmsgUnbound defines model for TypedTaggedEventStreamEnvelopeExtmsgUnbound.
type TypedTaggedEventStreamEnvelopeExtmsgUnbound struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload UnboundEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload UnboundEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeGcStoreDiskCritical defines model for TypedTaggedEventStreamEnvelopeGcStoreDiskCritical.
type TypedTaggedEventStreamEnvelopeGcStoreDiskCritical struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreDiskCriticalPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDiskCriticalPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeGcStoreDiskWarn defines model for TypedTaggedEventStreamEnvelopeGcStoreDiskWarn.
type TypedTaggedEventStreamEnvelopeGcStoreDiskWarn struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreDiskWarnPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDiskWarnPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone defines model for TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone.
type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceDone struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreMaintenanceDonePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreMaintenanceDonePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed defines model for TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed.
type TypedTaggedEventStreamEnvelopeGcStoreMaintenanceFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreMaintenanceFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreMaintenanceFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailArchived defines model for TypedTaggedEventStreamEnvelopeMailArchived.
type TypedTaggedEventStreamEnvelopeMailArchived struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailDeleted defines model for TypedTaggedEventStreamEnvelopeMailDeleted.
type TypedTaggedEventStreamEnvelopeMailDeleted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailMarkedRead defines model for TypedTaggedEventStreamEnvelopeMailMarkedRead.
type TypedTaggedEventStreamEnvelopeMailMarkedRead struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailMarkedUnread defines model for TypedTaggedEventStreamEnvelopeMailMarkedUnread.
type TypedTaggedEventStreamEnvelopeMailMarkedUnread struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailRead defines model for TypedTaggedEventStreamEnvelopeMailRead.
type TypedTaggedEventStreamEnvelopeMailRead struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailReplied defines model for TypedTaggedEventStreamEnvelopeMailReplied.
type TypedTaggedEventStreamEnvelopeMailReplied struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMailSent defines model for TypedTaggedEventStreamEnvelopeMailSent.
type TypedTaggedEventStreamEnvelopeMailSent struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MailEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MailEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeMoleculeResolved defines model for TypedTaggedEventStreamEnvelopeMoleculeResolved.
type TypedTaggedEventStreamEnvelopeMoleculeResolved struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload MoleculeResolvedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload MoleculeResolvedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeOrderCompleted defines model for TypedTaggedEventStreamEnvelopeOrderCompleted.
type TypedTaggedEventStreamEnvelopeOrderCompleted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeOrderFailed defines model for TypedTaggedEventStreamEnvelopeOrderFailed.
type TypedTaggedEventStreamEnvelopeOrderFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeOrderFired defines model for TypedTaggedEventStreamEnvelopeOrderFired.
type TypedTaggedEventStreamEnvelopeOrderFired struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen defines model for TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen.
type TypedTaggedEventStreamEnvelopeOrderGateTimeoutFailOpen struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload OrderGateTimeoutFailOpenPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload OrderGateTimeoutFailOpenPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopePgCredentialResolved defines model for TypedTaggedEventStreamEnvelopePgCredentialResolved.
type TypedTaggedEventStreamEnvelopePgCredentialResolved struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload PostgresCredentialResolvedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload PostgresCredentialResolvedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeProjectIdentityStamped defines model for TypedTaggedEventStreamEnvelopeProjectIdentityStamped.
type TypedTaggedEventStreamEnvelopeProjectIdentityStamped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload ProjectIdentityStampedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ProjectIdentityStampedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeProviderQuotaObserved defines model for TypedTaggedEventStreamEnvelopeProviderQuotaObserved.
type TypedTaggedEventStreamEnvelopeProviderQuotaObserved struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload QuotaObservedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload QuotaObservedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed defines model for TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed.
type TypedTaggedEventStreamEnvelopeProviderQuotaPollFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload QuotaPollFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload QuotaPollFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeProviderSwapped defines model for TypedTaggedEventStreamEnvelopeProviderSwapped.
type TypedTaggedEventStreamEnvelopeProviderSwapped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeProxyReaped defines model for TypedTaggedEventStreamEnvelopeProxyReaped.
type TypedTaggedEventStreamEnvelopeProxyReaped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload ProxyReapedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload ProxyReapedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestFailed defines model for TypedTaggedEventStreamEnvelopeRequestFailed.
type TypedTaggedEventStreamEnvelopeRequestFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload RequestFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RequestFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultCityCreate defines model for TypedTaggedEventStreamEnvelopeRequestResultCityCreate.
type TypedTaggedEventStreamEnvelopeRequestResultCityCreate struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload CityCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultCityUnregister defines model for TypedTaggedEventStreamEnvelopeRequestResultCityUnregister.
type TypedTaggedEventStreamEnvelopeRequestResultCityUnregister struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload CityUnregisterSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload CityUnregisterSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultRigCreate defines model for TypedTaggedEventStreamEnvelopeRequestResultRigCreate.
type TypedTaggedEventStreamEnvelopeRequestResultRigCreate struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload RigCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RigCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultSessionCreate defines model for TypedTaggedEventStreamEnvelopeRequestResultSessionCreate.
type TypedTaggedEventStreamEnvelopeRequestResultSessionCreate struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionCreateSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionCreateSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultSessionMessage defines model for TypedTaggedEventStreamEnvelopeRequestResultSessionMessage.
type TypedTaggedEventStreamEnvelopeRequestResultSessionMessage struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionMessageSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionMessageSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit defines model for TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit.
type TypedTaggedEventStreamEnvelopeRequestResultSessionSubmit struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionSubmitSucceededPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionSubmitSucceededPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeRigProvisionProgress defines model for TypedTaggedEventStreamEnvelopeRigProvisionProgress.
type TypedTaggedEventStreamEnvelopeRigProvisionProgress struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload RigProvisionProgressPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload RigProvisionProgressPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionColdStartTimeout defines model for TypedTaggedEventStreamEnvelopeSessionColdStartTimeout.
type TypedTaggedEventStreamEnvelopeSessionColdStartTimeout struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionCrashed defines model for TypedTaggedEventStreamEnvelopeSessionCrashed.
type TypedTaggedEventStreamEnvelopeSessionCrashed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork defines model for TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork.
type TypedTaggedEventStreamEnvelopeSessionDrainAckedWithAssignedWork struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionDrainAckedWithAssignedWorkPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionDrainAckedWithAssignedWorkPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionDraining defines model for TypedTaggedEventStreamEnvelopeSessionDraining.
type TypedTaggedEventStreamEnvelopeSessionDraining struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionIdleKilled defines model for TypedTaggedEventStreamEnvelopeSessionIdleKilled.
type TypedTaggedEventStreamEnvelopeSessionIdleKilled struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled defines model for TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled.
type TypedTaggedEventStreamEnvelopeSessionMaxAgeKilled struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionQuarantined defines model for TypedTaggedEventStreamEnvelopeSessionQuarantined.
type TypedTaggedEventStreamEnvelopeSessionQuarantined struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionResetStalled defines model for TypedTaggedEventStreamEnvelopeSessionResetStalled.
type TypedTaggedEventStreamEnvelopeSessionResetStalled struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionResetStalledPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionResetStalledPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionStopped defines model for TypedTaggedEventStreamEnvelopeSessionStopped.
type TypedTaggedEventStreamEnvelopeSessionStopped struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionStranded defines model for TypedTaggedEventStreamEnvelopeSessionStranded.
type TypedTaggedEventStreamEnvelopeSessionStranded struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionStrandedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionStrandedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionSuspended defines model for TypedTaggedEventStreamEnvelopeSessionSuspended.
type TypedTaggedEventStreamEnvelopeSessionSuspended struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionUndrained defines model for TypedTaggedEventStreamEnvelopeSessionUndrained.
type TypedTaggedEventStreamEnvelopeSessionUndrained struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionUnknownState defines model for TypedTaggedEventStreamEnvelopeSessionUnknownState.
type TypedTaggedEventStreamEnvelopeSessionUnknownState struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionUnknownStatePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionUnknownStatePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionUpdated defines model for TypedTaggedEventStreamEnvelopeSessionUpdated.
type TypedTaggedEventStreamEnvelopeSessionUpdated struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionWoke defines model for TypedTaggedEventStreamEnvelopeSessionWoke.
type TypedTaggedEventStreamEnvelopeSessionWoke struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload NoPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload NoPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed defines model for TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed.
type TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SessionLifecyclePayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SessionLifecyclePayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeStoreDegraded defines model for TypedTaggedEventStreamEnvelopeStoreDegraded.
type TypedTaggedEventStreamEnvelopeStoreDegraded struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreDegradedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreDegradedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeStoreProbeFailed defines model for TypedTaggedEventStreamEnvelopeStoreProbeFailed.
type TypedTaggedEventStreamEnvelopeStoreProbeFailed struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreProbeFailedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreProbeFailedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeStoreRecovered defines model for TypedTaggedEventStreamEnvelopeStoreRecovered.
type TypedTaggedEventStreamEnvelopeStoreRecovered struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload StoreRecoveredPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload StoreRecoveredPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick defines model for TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick.
type TypedTaggedEventStreamEnvelopeSupervisorFsPressureSkippedTick struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorFSPressureSkippedTickPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorFSPressureSkippedTickPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSupervisorRequest defines model for TypedTaggedEventStreamEnvelopeSupervisorRequest.
type TypedTaggedEventStreamEnvelopeSupervisorRequest struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorRequestPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorRequestPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested defines model for TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested.
type TypedTaggedEventStreamEnvelopeSupervisorShutdownRequested struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorShutdownPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorShutdownPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeSupervisorStarted defines model for TypedTaggedEventStreamEnvelopeSupervisorStarted.
type TypedTaggedEventStreamEnvelopeSupervisorStarted struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload SupervisorStartedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload SupervisorStartedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeWebhookReceived defines model for TypedTaggedEventStreamEnvelopeWebhookReceived.
type TypedTaggedEventStreamEnvelopeWebhookReceived struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload WebhookReceivedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WebhookReceivedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeWebhookRejected defines model for TypedTaggedEventStreamEnvelopeWebhookRejected.
type TypedTaggedEventStreamEnvelopeWebhookRejected struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload WebhookRejectedPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WebhookRejectedPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// TypedTaggedEventStreamEnvelopeWorkerOperation defines model for TypedTaggedEventStreamEnvelopeWorkerOperation.
type TypedTaggedEventStreamEnvelopeWorkerOperation struct {
- Actor string `json:"actor"`
- City string `json:"city"`
- Message *string `json:"message,omitempty"`
- Payload WorkerOperationEventPayload `json:"payload"`
- RunId *string `json:"run_id,omitempty"`
- Seq int64 `json:"seq"`
- SessionId *string `json:"session_id,omitempty"`
- StepId *string `json:"step_id,omitempty"`
- Subject *string `json:"subject,omitempty"`
- Ts time.Time `json:"ts"`
- Type string `json:"type"`
- Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
+ Actor string `json:"actor"`
+ City string `json:"city"`
+ DependsOnStepIds *[]string `json:"depends_on_step_ids,omitempty"`
+ Message *string `json:"message,omitempty"`
+ Payload WorkerOperationEventPayload `json:"payload"`
+ RunId *string `json:"run_id,omitempty"`
+ Seq int64 `json:"seq"`
+ SessionId *string `json:"session_id,omitempty"`
+ StepId *string `json:"step_id,omitempty"`
+ Subject *string `json:"subject,omitempty"`
+ Ts time.Time `json:"ts"`
+ Type string `json:"type"`
+ Workflow *WorkflowEventProjection `json:"workflow,omitempty"`
}
// UnboundEventPayload defines model for UnboundEventPayload.
@@ -13372,6 +13617,62 @@ func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeEventsRotated(v
return err
}
+// AsTypedEventStreamEnvelopeExecutionStepDefined returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeExecutionStepDefined
+func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeExecutionStepDefined() (TypedEventStreamEnvelopeExecutionStepDefined, error) {
+ var body TypedEventStreamEnvelopeExecutionStepDefined
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromTypedEventStreamEnvelopeExecutionStepDefined overwrites any union data inside the TypedEventStreamEnvelope as the provided TypedEventStreamEnvelopeExecutionStepDefined
+func (t *TypedEventStreamEnvelope) FromTypedEventStreamEnvelopeExecutionStepDefined(v TypedEventStreamEnvelopeExecutionStepDefined) error {
+ v.Type = "execution.step_defined"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeTypedEventStreamEnvelopeExecutionStepDefined performs a merge with any union data inside the TypedEventStreamEnvelope, using the provided TypedEventStreamEnvelopeExecutionStepDefined
+func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeExecutionStepDefined(v TypedEventStreamEnvelopeExecutionStepDefined) error {
+ v.Type = "execution.step_defined"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsTypedEventStreamEnvelopeExecutionWorkAssociated returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeExecutionWorkAssociated
+func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeExecutionWorkAssociated() (TypedEventStreamEnvelopeExecutionWorkAssociated, error) {
+ var body TypedEventStreamEnvelopeExecutionWorkAssociated
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromTypedEventStreamEnvelopeExecutionWorkAssociated overwrites any union data inside the TypedEventStreamEnvelope as the provided TypedEventStreamEnvelopeExecutionWorkAssociated
+func (t *TypedEventStreamEnvelope) FromTypedEventStreamEnvelopeExecutionWorkAssociated(v TypedEventStreamEnvelopeExecutionWorkAssociated) error {
+ v.Type = "execution.work_associated"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeTypedEventStreamEnvelopeExecutionWorkAssociated performs a merge with any union data inside the TypedEventStreamEnvelope, using the provided TypedEventStreamEnvelopeExecutionWorkAssociated
+func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeExecutionWorkAssociated(v TypedEventStreamEnvelopeExecutionWorkAssociated) error {
+ v.Type = "execution.work_associated"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
// AsTypedEventStreamEnvelopeExtmsgAdapterAdded returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeExtmsgAdapterAdded
func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeExtmsgAdapterAdded() (TypedEventStreamEnvelopeExtmsgAdapterAdded, error) {
var body TypedEventStreamEnvelopeExtmsgAdapterAdded
@@ -15254,6 +15555,10 @@ func (t TypedEventStreamEnvelope) ValueByDiscriminator() (interface{}, error) {
return t.AsTypedEventStreamEnvelopeEmergencySignaled()
case "events.rotated":
return t.AsTypedEventStreamEnvelopeEventsRotated()
+ case "execution.step_defined":
+ return t.AsTypedEventStreamEnvelopeExecutionStepDefined()
+ case "execution.work_associated":
+ return t.AsTypedEventStreamEnvelopeExecutionWorkAssociated()
case "extmsg.adapter_added":
return t.AsTypedEventStreamEnvelopeExtmsgAdapterAdded()
case "extmsg.adapter_removed":
@@ -16041,6 +16346,62 @@ func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeEven
return err
}
+// AsTypedTaggedEventStreamEnvelopeExecutionStepDefined returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeExecutionStepDefined
+func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeExecutionStepDefined() (TypedTaggedEventStreamEnvelopeExecutionStepDefined, error) {
+ var body TypedTaggedEventStreamEnvelopeExecutionStepDefined
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromTypedTaggedEventStreamEnvelopeExecutionStepDefined overwrites any union data inside the TypedTaggedEventStreamEnvelope as the provided TypedTaggedEventStreamEnvelopeExecutionStepDefined
+func (t *TypedTaggedEventStreamEnvelope) FromTypedTaggedEventStreamEnvelopeExecutionStepDefined(v TypedTaggedEventStreamEnvelopeExecutionStepDefined) error {
+ v.Type = "execution.step_defined"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeTypedTaggedEventStreamEnvelopeExecutionStepDefined performs a merge with any union data inside the TypedTaggedEventStreamEnvelope, using the provided TypedTaggedEventStreamEnvelopeExecutionStepDefined
+func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeExecutionStepDefined(v TypedTaggedEventStreamEnvelopeExecutionStepDefined) error {
+ v.Type = "execution.step_defined"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
+// AsTypedTaggedEventStreamEnvelopeExecutionWorkAssociated returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeExecutionWorkAssociated
+func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeExecutionWorkAssociated() (TypedTaggedEventStreamEnvelopeExecutionWorkAssociated, error) {
+ var body TypedTaggedEventStreamEnvelopeExecutionWorkAssociated
+ err := json.Unmarshal(t.union, &body)
+ return body, err
+}
+
+// FromTypedTaggedEventStreamEnvelopeExecutionWorkAssociated overwrites any union data inside the TypedTaggedEventStreamEnvelope as the provided TypedTaggedEventStreamEnvelopeExecutionWorkAssociated
+func (t *TypedTaggedEventStreamEnvelope) FromTypedTaggedEventStreamEnvelopeExecutionWorkAssociated(v TypedTaggedEventStreamEnvelopeExecutionWorkAssociated) error {
+ v.Type = "execution.work_associated"
+ b, err := json.Marshal(v)
+ t.union = b
+ return err
+}
+
+// MergeTypedTaggedEventStreamEnvelopeExecutionWorkAssociated performs a merge with any union data inside the TypedTaggedEventStreamEnvelope, using the provided TypedTaggedEventStreamEnvelopeExecutionWorkAssociated
+func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeExecutionWorkAssociated(v TypedTaggedEventStreamEnvelopeExecutionWorkAssociated) error {
+ v.Type = "execution.work_associated"
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+
+ merged, err := runtime.JSONMerge(t.union, b)
+ t.union = merged
+ return err
+}
+
// AsTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded
func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded() (TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded, error) {
var body TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded
@@ -17923,6 +18284,10 @@ func (t TypedTaggedEventStreamEnvelope) ValueByDiscriminator() (interface{}, err
return t.AsTypedTaggedEventStreamEnvelopeEmergencySignaled()
case "events.rotated":
return t.AsTypedTaggedEventStreamEnvelopeEventsRotated()
+ case "execution.step_defined":
+ return t.AsTypedTaggedEventStreamEnvelopeExecutionStepDefined()
+ case "execution.work_associated":
+ return t.AsTypedTaggedEventStreamEnvelopeExecutionWorkAssociated()
case "extmsg.adapter_added":
return t.AsTypedTaggedEventStreamEnvelopeExtmsgAdapterAdded()
case "extmsg.adapter_removed":
diff --git a/internal/api/genclient/genclient_test.go b/internal/api/genclient/genclient_test.go
index edd706e6ad..19ce2cee07 100644
--- a/internal/api/genclient/genclient_test.go
+++ b/internal/api/genclient/genclient_test.go
@@ -2,10 +2,14 @@ package genclient_test
import (
"bytes"
+ "encoding/json"
"os"
"os/exec"
"path/filepath"
+ "slices"
"testing"
+
+ "github.com/gastownhall/gascity/internal/api/genclient"
)
// TestGeneratedClientInSync regenerates client_gen.go from the live spec
@@ -53,6 +57,50 @@ func TestGeneratedClientInSync(t *testing.T) {
}
}
+func TestEventStreamEnvelopePreservesTopologyPresence(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ deps *[]string
+ wantPresent bool
+ }{
+ {name: "unknown"},
+ {name: "root", deps: ptrToStrings([]string{}), wantPresent: true},
+ {name: "dependent", deps: ptrToStrings([]string{"build"}), wantPresent: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ encoded, err := json.Marshal(genclient.EventStreamEnvelope{DependsOnStepIds: tc.deps})
+ if err != nil {
+ t.Fatalf("marshal envelope: %v", err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(encoded, &fields); err != nil {
+ t.Fatalf("unmarshal fields: %v", err)
+ }
+ _, present := fields["depends_on_step_ids"]
+ if present != tc.wantPresent {
+ t.Fatalf("topology field present = %v, want %v; JSON = %s", present, tc.wantPresent, encoded)
+ }
+
+ var decoded genclient.EventStreamEnvelope
+ if err := json.Unmarshal(encoded, &decoded); err != nil {
+ t.Fatalf("unmarshal envelope: %v", err)
+ }
+ if !sameStepDependencies(decoded.DependsOnStepIds, tc.deps) {
+ t.Fatalf("round-trip dependencies = %#v, want %#v", decoded.DependsOnStepIds, tc.deps)
+ }
+ })
+ }
+}
+
+func ptrToStrings(values []string) *[]string { return &values }
+
+func sameStepDependencies(got, want *[]string) bool {
+ if got == nil || want == nil {
+ return got == nil && want == nil
+ }
+ return slices.Equal(*got, *want)
+}
+
// findRepoRoot walks up from the current working directory until it
// finds a go.mod file.
func findRepoRoot() (string, error) {
diff --git a/internal/api/handler_packs_write_test.go b/internal/api/handler_packs_write_test.go
index 21f2746ca4..c415f0a605 100644
--- a/internal/api/handler_packs_write_test.go
+++ b/internal/api/handler_packs_write_test.go
@@ -1,13 +1,18 @@
package api
import (
+ "encoding/json"
+ "errors"
+ "fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "github.com/gastownhall/gascity/internal/api/apierr"
"github.com/gastownhall/gascity/internal/fsys"
+ "github.com/gastownhall/gascity/internal/gitcred"
"github.com/gastownhall/gascity/internal/importsvc"
)
@@ -65,6 +70,69 @@ func TestHandlePackAdd(t *testing.T) {
}
}
+func TestHandlePackAddMapsAuthErrorToCredentialRequiredConflict(t *testing.T) {
+ restoreResolver := stubPackSourceResolver(t, map[string][]net.IP{
+ "github.com": {net.ParseIP("140.82.112.3")},
+ })
+ defer restoreResolver()
+
+ const secret = "ghp_must_not_reach_the_response"
+ orig := packAddImport
+ packAddImport = func(fsys.FS, string, string, string, string) (*importsvc.AddResult, error) {
+ return nil, fmt.Errorf("resolving pack version: %w", &gitcred.AuthError{
+ Host: "github.com",
+ OrgPrefix: "github.com/gascity",
+ Repo: "https://github.com/gascity/maintainer-city",
+ Output: "fatal: Authentication failed for " + secret,
+ Err: errors.New(secret),
+ })
+ }
+ defer func() { packAddImport = orig }()
+
+ state := newFakeMutatorState(t)
+ h := newTestCityHandler(t, state)
+ req := httptest.NewRequest(http.MethodPost, cityURL(state, "/packs"),
+ strings.NewReader(`{"source":"https://github.com/gascity/maintainer-city/tree/main"}`))
+ req.Header.Set("X-GC-Request", "true")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want 409; body = %s", rec.Code, rec.Body.String())
+ }
+ var problem apierr.ErrorModel
+ if err := json.Unmarshal(rec.Body.Bytes(), &problem); err != nil {
+ t.Fatalf("decode problem response: %v; body = %s", err, rec.Body.String())
+ }
+ if problem.Type != "urn:gascity:error:pack-credential-required" ||
+ problem.Code != "pack-credential-required" {
+ t.Fatalf("type/code = %q/%q, want pack-credential-required; body = %s",
+ problem.Type, problem.Code, rec.Body.String())
+ }
+ wantDetails := map[string]string{
+ "body.host": "github.com/gascity",
+ "body.repo": "https://github.com/gascity/maintainer-city",
+ "body.hint": "register a pack credential for this host",
+ }
+ for _, detail := range problem.Errors {
+ value, ok := detail.Value.(string)
+ if !ok {
+ continue
+ }
+ if want, exists := wantDetails[detail.Location]; exists && value == want {
+ delete(wantDetails, detail.Location)
+ }
+ }
+ if len(wantDetails) != 0 {
+ t.Fatalf("missing safe credential details %v; body = %s", wantDetails, rec.Body.String())
+ }
+ for _, forbidden := range []string{secret, "Authentication failed"} {
+ if strings.Contains(rec.Body.String(), forbidden) {
+ t.Fatalf("credential response leaked %q: %s", forbidden, rec.Body.String())
+ }
+ }
+}
+
func TestHandlePackRemove(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/internal/api/handler_sling.go b/internal/api/handler_sling.go
index e34c049134..3258ef4592 100644
--- a/internal/api/handler_sling.go
+++ b/internal/api/handler_sling.go
@@ -116,12 +116,14 @@ func (s *Server) execSling(ctx context.Context, body slingBody, _ string) (*slin
sourceWorkflowScanWarnings := make(map[string]struct{})
var sourceWorkflowScanMessages []string
deps := sling.SlingDeps{
- CityName: s.state.CityName(),
- CityPath: s.state.CityPath(),
- Cfg: s.state.Config(),
- SP: s.state.SessionProvider(),
- Store: store,
- StoreRef: storeRef,
+ CityName: s.state.CityName(),
+ CityPath: s.state.CityPath(),
+ Cfg: s.state.Config(),
+ SP: s.state.SessionProvider(),
+ Store: store,
+ GraphStore: s.state.GraphBeadStore().Store,
+ Events: s.state.EventProvider(),
+ StoreRef: storeRef,
SourceWorkflowStores: func() ([]sling.SourceWorkflowStore, error) {
return s.sourceWorkflowStores(), nil
},
diff --git a/internal/api/handler_status.go b/internal/api/handler_status.go
index e2c52b7c79..f776b6c4c2 100644
--- a/internal/api/handler_status.go
+++ b/internal/api/handler_status.go
@@ -157,9 +157,18 @@ func (s *Server) buildStatusBody(ctx context.Context, lite bool) StatusBody {
var rawRunning int
agentDetails := make([]StatusAgentDetail, 0, len(cfg.Agents))
suspendedRigs := make(map[string]bool, len(cfg.Rigs))
+ // cacheColdRigs mirrors the controller's per-rig cache refresh gate
+ // (rigStoreBackgroundRefresh): a rig suspended by EFFECTIVE state gets no
+ // async full prime and no reconciler, so its cache never reaches live and
+ // the cache-only Ready projection can never answer. It is deliberately not
+ // the same set as suspendedRigs, which grows below to include rigs merely
+ // inferred suspended because every one of their agents is — those keep a
+ // refreshing cache and must still be asked for ready work.
+ cacheColdRigs := make(map[string]bool, len(cfg.Rigs))
for _, r := range cfg.Rigs {
if suspensionstate.EffectiveRigSuspended(citySt, r.Name, r.EffectiveSuspendedOnStart()) {
suspendedRigs[r.Name] = true
+ cacheColdRigs[r.Name] = true
}
}
perRigAgentTotals := make(map[string]int, len(cfg.Rigs))
@@ -258,7 +267,7 @@ func (s *Server) buildStatusBody(ctx context.Context, lite bool) StatusBody {
var wc workCounts
if !lite {
var workErrs []string
- wc, workErrs = s.statusWorkCounts(ctx)
+ wc, workErrs = s.statusWorkCounts(ctx, cacheColdRigs)
partialErrors = append(partialErrors, workErrs...)
}
@@ -588,7 +597,14 @@ type statusWorkResult struct {
// beads.Counter answer persisted counts without hydrating rows — the caching
// layer counts matches in memory when its cache is clean (#1896). Stores are
// queried concurrently; results aggregate in deterministic city/rig order.
-func (s *Server) statusWorkCounts(ctx context.Context) (workCounts, []string) {
+//
+// Rigs in cacheColdRigs are asked for persisted counts but not for ready work.
+// Their store runs no background cache refresh, so the cache-only Ready
+// projection is guaranteed to decline with ErrCacheUnavailable — reporting that
+// as a partial error made every city with a suspended rig permanently partial,
+// which greys out unrelated status tiles in the dashboard. Skipping the read
+// changes no count: the failing read already contributed zero ready work.
+func (s *Server) statusWorkCounts(ctx context.Context, cacheColdRigs map[string]bool) (workCounts, []string) {
stores := s.state.BeadStores()
// sortedRigNames deduplicates rigs sharing one store instance, so each
// store's persisted statuses are counted exactly once.
@@ -613,7 +629,7 @@ func (s *Server) statusWorkCounts(ctx context.Context) (workCounts, []string) {
label: "rig " + rigName,
store: stores[rigName],
includeStored: true,
- includeReady: rigName != cityName,
+ includeReady: rigName != cityName && !cacheColdRigs[rigName],
})
}
diff --git a/internal/api/handler_status_suspended_ready_test.go b/internal/api/handler_status_suspended_ready_test.go
new file mode 100644
index 0000000000..7557755d3b
--- /dev/null
+++ b/internal/api/handler_status_suspended_ready_test.go
@@ -0,0 +1,85 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+)
+
+// coldCacheStore models a rig store whose cache runs no background refresh:
+// persisted counts answer normally, but the cache-only Ready projection always
+// declines with ErrCacheUnavailable, exactly as CachingStore.ReadyContext does
+// for a store that never reached cacheLive.
+type coldCacheStore struct {
+ beads.Store
+ readyCalls atomic.Int32
+}
+
+func (c *coldCacheStore) ReadyContext(context.Context, ...beads.ReadyQuery) ([]beads.Bead, error) {
+ c.readyCalls.Add(1)
+ return nil, fmt.Errorf("reading complete ready projection from cache: %w", beads.ErrCacheUnavailable)
+}
+
+func newColdCacheRigState(t *testing.T) (*fakeState, *coldCacheStore) {
+ t.Helper()
+ backing := beads.NewMemStore()
+ if _, err := backing.Create(beads.Bead{Type: "task", Title: "rig work", Status: "open"}); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ cold := &coldCacheStore{Store: backing}
+ state := newFakeState(t)
+ state.stores = map[string]beads.Store{"myrig": cold}
+ state.cityBeadStore = nil
+ return state, cold
+}
+
+// TestStatusWorkCountsSkipsReadyForCacheColdRigs is the regression for the
+// permanently-partial status bug: a suspended rig gets no background cache
+// refresh (rigStoreBackgroundRefresh), so its cache-only Ready read can never
+// succeed. Asking anyway made /status report partial: true forever, which the
+// dashboard renders by grey-dotting every systems tile — dolt store, mail and
+// agents alike — even though all of them are healthy.
+func TestStatusWorkCountsSkipsReadyForCacheColdRigs(t *testing.T) {
+ state, cold := newColdCacheRigState(t)
+ s := &Server{state: state}
+
+ wc, errs := s.statusWorkCounts(context.Background(), map[string]bool{"myrig": true})
+
+ if len(errs) != 0 {
+ t.Fatalf("partial errors = %v, want none for a cache-cold rig", errs)
+ }
+ if got := cold.readyCalls.Load(); got != 0 {
+ t.Errorf("ready reads = %d, want 0 — the read is known to fail, so it must be skipped", got)
+ }
+ if wc.Open != 1 {
+ t.Errorf("Open = %d, want 1 — persisted counts must still be collected", wc.Open)
+ }
+ if wc.Ready != 0 {
+ t.Errorf("Ready = %d, want 0 — a cache-cold rig contributes no ready work", wc.Ready)
+ }
+}
+
+// TestStatusWorkCountsStillReportsReadyFailureForRefreshingRigs pins the other
+// half: when a rig is NOT cache-cold, a declining Ready read is a genuine
+// problem and must still surface as a partial error. The fix must not silence
+// cache failures on rigs whose cache is supposed to be live.
+func TestStatusWorkCountsStillReportsReadyFailureForRefreshingRigs(t *testing.T) {
+ state, cold := newColdCacheRigState(t)
+ s := &Server{state: state}
+
+ _, errs := s.statusWorkCounts(context.Background(), nil)
+
+ if len(errs) != 1 {
+ t.Fatalf("partial errors = %v, want exactly 1", errs)
+ }
+ if !strings.Contains(errs[0], "rig myrig work ready:") {
+ t.Errorf("partial error = %q, want it to name the rig's ready read", errs[0])
+ }
+ if got := cold.readyCalls.Load(); got != 1 {
+ t.Errorf("ready reads = %d, want 1 — a refreshing rig must still be asked", got)
+ }
+}
diff --git a/internal/api/huma_handlers_packs.go b/internal/api/huma_handlers_packs.go
index 40a15f6836..47c29ba915 100644
--- a/internal/api/huma_handlers_packs.go
+++ b/internal/api/huma_handlers_packs.go
@@ -4,10 +4,12 @@ import (
"context"
"errors"
"sort"
+ "strings"
"github.com/danielgtaylor/huma/v2"
"github.com/gastownhall/gascity/internal/api/apierr"
"github.com/gastownhall/gascity/internal/fsys"
+ "github.com/gastownhall/gascity/internal/gitcred"
"github.com/gastownhall/gascity/internal/importsvc"
)
@@ -178,7 +180,10 @@ func (s *Server) serializeConfigWrite(fn func() error) error {
// packImportHTTPError maps importsvc sentinels to RFC 9457 problem responses.
func packImportHTTPError(err error) error {
+ var authErr *gitcred.AuthError
switch {
+ case errors.As(err, &authErr):
+ return packCredentialRequiredProblem(authErr)
case errors.Is(err, importsvc.ErrInvalidSource), errors.Is(err, importsvc.ErrScopeLoad),
errors.Is(err, importsvc.ErrNameDerive), errors.Is(err, importsvc.ErrReservedPrefix):
// ErrNameDerive and ErrReservedPrefix are client input-validation failures
@@ -202,3 +207,30 @@ func packImportHTTPError(err error) error {
return apierr.Internal.With("pack import failed", &huma.ErrorDetail{Message: err.Error()})
}
}
+
+// packCredentialRequiredProblem projects only safe, URL-derived context from an
+// authentication failure. In particular, AuthError.Output, RuleOrigin, Err, and
+// Error() are intentionally excluded because git/backend error text may contain
+// credentials or internal secret-mount paths.
+func packCredentialRequiredProblem(authErr *gitcred.AuthError) error {
+ host := strings.TrimSpace(authErr.OrgPrefix)
+ if host == "" {
+ host = strings.TrimSpace(authErr.Host)
+ }
+ if host == "" {
+ return apierr.BadGateway.Msg("pack source authentication failed")
+ }
+
+ details := []*huma.ErrorDetail{
+ {Location: "body.host", Value: host},
+ }
+ if repo := strings.TrimSpace(authErr.Repo); repo != "" {
+ details = append(details, &huma.ErrorDetail{Location: "body.repo", Value: repo})
+ }
+ hint := "register a pack credential for this host"
+ if authErr.Matched {
+ hint = "rotate the pack credential for this host"
+ }
+ details = append(details, &huma.ErrorDetail{Location: "body.hint", Value: hint})
+ return apierr.PackCredentialRequired.With("pack source authentication requires a credential", details...)
+}
diff --git a/internal/api/huma_sse_test.go b/internal/api/huma_sse_test.go
index 4651f41689..138f6169cd 100644
--- a/internal/api/huma_sse_test.go
+++ b/internal/api/huma_sse_test.go
@@ -420,9 +420,10 @@ func assertTypedEventEnvelopeUnion(t *testing.T, spec map[string]any, schemaName
if gotPayloadRef != wantPayloadRef {
t.Fatalf("%s variant %s payload ref = %q, want %q", schemaName, eventType, gotPayloadRef, wantPayloadRef)
}
+ assertOptionalStepDependenciesSchema(t, schemaName, eventType, properties)
wantRequired := []string{"seq", "type", "ts", "actor", "payload"}
- wantProperties := []string{"seq", "type", "ts", "actor", "subject", "message", "workflow", "payload"}
+ wantProperties := []string{"seq", "type", "ts", "actor", "subject", "message", "workflow", "payload", "depends_on_step_ids"}
if cityField {
wantRequired = append(wantRequired, "city")
wantProperties = append(wantProperties, "city")
@@ -504,9 +505,10 @@ func assertCustomEventEnvelopeVariant(
if len(payloadProperty) != 0 {
t.Fatalf("%s custom variant %s payload schema = %#v, want unconstrained custom JSON", schemaName, ref, payloadProperty)
}
+ assertOptionalStepDependenciesSchema(t, schemaName, "custom", properties)
wantRequired := []string{"seq", "type", "ts", "actor", "payload"}
- wantProperties := []string{"seq", "type", "ts", "actor", "subject", "message", "workflow", "payload"}
+ wantProperties := []string{"seq", "type", "ts", "actor", "subject", "message", "workflow", "payload", "depends_on_step_ids"}
if cityField {
wantRequired = append(wantRequired, "city")
wantProperties = append(wantProperties, "city")
@@ -515,6 +517,24 @@ func assertCustomEventEnvelopeVariant(
assertRequiredFields(t, schemaName, "custom", variant, wantRequired)
}
+func assertOptionalStepDependenciesSchema(t *testing.T, schemaName, variant string, properties map[string]any) {
+ t.Helper()
+ dependencies, ok := properties["depends_on_step_ids"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s %s depends_on_step_ids property missing", schemaName, variant)
+ }
+ if got, _ := dependencies["type"].(string); got != "array" {
+ t.Fatalf("%s %s depends_on_step_ids type = %q, want array", schemaName, variant, got)
+ }
+ items, ok := dependencies["items"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s %s depends_on_step_ids items missing", schemaName, variant)
+ }
+ if got, _ := items["type"].(string); got != "string" {
+ t.Fatalf("%s %s depends_on_step_ids item type = %q, want string", schemaName, variant, got)
+ }
+}
+
func typedEventDiscriminatorMapping(t *testing.T, union map[string]any, schemaName string) map[string]string {
t.Helper()
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index 32f77493cc..f6f337060b 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -168,6 +168,13 @@
"null"
]
},
+ "AssignedWorkDeferLimit": {
+ "format": "int64",
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"Attach": {
"type": [
"boolean",
@@ -526,6 +533,7 @@
"IdleTimeout",
"MaxSessionAge",
"MaxSessionAgeJitter",
+ "AssignedWorkDeferLimit",
"SleepAfterIdle",
"InstallAgentHooks",
"Skills",
@@ -2316,6 +2324,7 @@
"urn:gascity:error:not-implemented",
"urn:gascity:error:operation-in-progress",
"urn:gascity:error:order-not-found",
+ "urn:gascity:error:pack-credential-required",
"urn:gascity:error:pack-not-found",
"urn:gascity:error:patch-not-found",
"urn:gascity:error:provider-not-found",
@@ -2363,6 +2372,7 @@
"urn:gascity:error:not-implemented",
"urn:gascity:error:operation-in-progress",
"urn:gascity:error:order-not-found",
+ "urn:gascity:error:pack-credential-required",
"urn:gascity:error:pack-not-found",
"urn:gascity:error:patch-not-found",
"urn:gascity:error:provider-not-found",
@@ -2697,6 +2707,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12092,6 +12108,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12178,6 +12200,8 @@
"emergency.acked": "#/components/schemas/TypedEventStreamEnvelopeEmergencyAcked",
"emergency.signaled": "#/components/schemas/TypedEventStreamEnvelopeEmergencySignaled",
"events.rotated": "#/components/schemas/TypedEventStreamEnvelopeEventsRotated",
+ "execution.step_defined": "#/components/schemas/TypedEventStreamEnvelopeExecutionStepDefined",
+ "execution.work_associated": "#/components/schemas/TypedEventStreamEnvelopeExecutionWorkAssociated",
"extmsg.adapter_added": "#/components/schemas/TypedEventStreamEnvelopeExtmsgAdapterAdded",
"extmsg.adapter_removed": "#/components/schemas/TypedEventStreamEnvelopeExtmsgAdapterRemoved",
"extmsg.bound": "#/components/schemas/TypedEventStreamEnvelopeExtmsgBound",
@@ -12315,6 +12339,12 @@
{
"$ref": "#/components/schemas/TypedEventStreamEnvelopeEventsRotated"
},
+ {
+ "$ref": "#/components/schemas/TypedEventStreamEnvelopeExecutionStepDefined"
+ },
+ {
+ "$ref": "#/components/schemas/TypedEventStreamEnvelopeExecutionWorkAssociated"
+ },
{
"$ref": "#/components/schemas/TypedEventStreamEnvelopeExtmsgAdapterAdded"
},
@@ -12519,6 +12549,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12570,6 +12606,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12621,6 +12663,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12672,6 +12720,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12723,6 +12777,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12774,6 +12834,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12825,6 +12891,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12876,6 +12948,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12927,6 +13005,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -12978,6 +13062,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13029,6 +13119,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13080,6 +13176,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13131,6 +13233,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13182,6 +13290,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13233,6 +13347,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13284,6 +13404,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13335,6 +13461,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13386,6 +13518,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13437,6 +13575,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13488,6 +13632,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13540,6 +13690,8 @@
"bead.worktree.reap_skipped",
"bead.claim_rejected",
"bead.dead_assignee_reopened",
+ "execution.work_associated",
+ "execution.step_defined",
"mail.sent",
"mail.read",
"mail.archived",
@@ -13627,6 +13779,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13678,6 +13836,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13729,6 +13893,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13780,6 +13950,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -13825,17 +14001,23 @@
"title": "TypedEventStreamEnvelope events.rotated",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgAdapterAdded": {
+ "TypedEventStreamEnvelopeExecutionStepDefined": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/AdapterEventPayload"
+ "$ref": "#/components/schemas/NoPayload"
},
"run_id": {
"type": "string"
@@ -13859,7 +14041,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.adapter_added",
+ "const": "execution.step_defined",
"type": "string"
},
"workflow": {
@@ -13873,20 +14055,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.adapter_added",
+ "title": "TypedEventStreamEnvelope execution.step_defined",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgAdapterRemoved": {
+ "TypedEventStreamEnvelopeExecutionWorkAssociated": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/AdapterEventPayload"
+ "$ref": "#/components/schemas/NoPayload"
},
"run_id": {
"type": "string"
@@ -13910,7 +14098,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.adapter_removed",
+ "const": "execution.work_associated",
"type": "string"
},
"workflow": {
@@ -13924,20 +14112,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.adapter_removed",
+ "title": "TypedEventStreamEnvelope execution.work_associated",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgBound": {
+ "TypedEventStreamEnvelopeExtmsgAdapterAdded": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/BoundEventPayload"
+ "$ref": "#/components/schemas/AdapterEventPayload"
},
"run_id": {
"type": "string"
@@ -13961,7 +14155,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.bound",
+ "const": "extmsg.adapter_added",
"type": "string"
},
"workflow": {
@@ -13975,20 +14169,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.bound",
+ "title": "TypedEventStreamEnvelope extmsg.adapter_added",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgGroupCreated": {
+ "TypedEventStreamEnvelopeExtmsgAdapterRemoved": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/GroupCreatedEventPayload"
+ "$ref": "#/components/schemas/AdapterEventPayload"
},
"run_id": {
"type": "string"
@@ -14012,7 +14212,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.group_created",
+ "const": "extmsg.adapter_removed",
"type": "string"
},
"workflow": {
@@ -14026,20 +14226,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.group_created",
+ "title": "TypedEventStreamEnvelope extmsg.adapter_removed",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgInbound": {
+ "TypedEventStreamEnvelopeExtmsgBound": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/InboundEventPayload"
+ "$ref": "#/components/schemas/BoundEventPayload"
},
"run_id": {
"type": "string"
@@ -14063,7 +14269,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.inbound",
+ "const": "extmsg.bound",
"type": "string"
},
"workflow": {
@@ -14077,20 +14283,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.inbound",
+ "title": "TypedEventStreamEnvelope extmsg.bound",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgOutbound": {
+ "TypedEventStreamEnvelopeExtmsgGroupCreated": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/OutboundEventPayload"
+ "$ref": "#/components/schemas/GroupCreatedEventPayload"
},
"run_id": {
"type": "string"
@@ -14114,7 +14326,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.outbound",
+ "const": "extmsg.group_created",
"type": "string"
},
"workflow": {
@@ -14128,20 +14340,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.outbound",
+ "title": "TypedEventStreamEnvelope extmsg.group_created",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch": {
+ "TypedEventStreamEnvelopeExtmsgInbound": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/OutboundChannelMismatchPayload"
+ "$ref": "#/components/schemas/InboundEventPayload"
},
"run_id": {
"type": "string"
@@ -14165,7 +14383,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.outbound_channel_mismatch",
+ "const": "extmsg.inbound",
"type": "string"
},
"workflow": {
@@ -14179,20 +14397,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.outbound_channel_mismatch",
+ "title": "TypedEventStreamEnvelope extmsg.inbound",
"type": "object"
},
- "TypedEventStreamEnvelopeExtmsgUnbound": {
+ "TypedEventStreamEnvelopeExtmsgOutbound": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/UnboundEventPayload"
+ "$ref": "#/components/schemas/OutboundEventPayload"
},
"run_id": {
"type": "string"
@@ -14216,7 +14440,7 @@
"type": "string"
},
"type": {
- "const": "extmsg.unbound",
+ "const": "extmsg.outbound",
"type": "string"
},
"workflow": {
@@ -14230,20 +14454,26 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope extmsg.unbound",
+ "title": "TypedEventStreamEnvelope extmsg.outbound",
"type": "object"
},
- "TypedEventStreamEnvelopeGcStoreDiskCritical": {
+ "TypedEventStreamEnvelopeExtmsgOutboundChannelMismatch": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
"payload": {
- "$ref": "#/components/schemas/StoreDiskCriticalPayload"
+ "$ref": "#/components/schemas/OutboundChannelMismatchPayload"
},
"run_id": {
"type": "string"
@@ -14267,7 +14497,7 @@
"type": "string"
},
"type": {
- "const": "gc.store.disk_critical",
+ "const": "extmsg.outbound_channel_mismatch",
"type": "string"
},
"workflow": {
@@ -14281,18 +14511,138 @@
"actor",
"payload"
],
- "title": "TypedEventStreamEnvelope gc.store.disk_critical",
+ "title": "TypedEventStreamEnvelope extmsg.outbound_channel_mismatch",
"type": "object"
},
- "TypedEventStreamEnvelopeGcStoreDiskWarn": {
+ "TypedEventStreamEnvelopeExtmsgUnbound": {
"additionalProperties": false,
"properties": {
"actor": {
"type": "string"
},
- "message": {
- "type": "string"
- },
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "message": {
+ "type": "string"
+ },
+ "payload": {
+ "$ref": "#/components/schemas/UnboundEventPayload"
+ },
+ "run_id": {
+ "type": "string"
+ },
+ "seq": {
+ "format": "int64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "step_id": {
+ "type": "string"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "ts": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "type": {
+ "const": "extmsg.unbound",
+ "type": "string"
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/WorkflowEventProjection"
+ }
+ },
+ "required": [
+ "seq",
+ "type",
+ "ts",
+ "actor",
+ "payload"
+ ],
+ "title": "TypedEventStreamEnvelope extmsg.unbound",
+ "type": "object"
+ },
+ "TypedEventStreamEnvelopeGcStoreDiskCritical": {
+ "additionalProperties": false,
+ "properties": {
+ "actor": {
+ "type": "string"
+ },
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "message": {
+ "type": "string"
+ },
+ "payload": {
+ "$ref": "#/components/schemas/StoreDiskCriticalPayload"
+ },
+ "run_id": {
+ "type": "string"
+ },
+ "seq": {
+ "format": "int64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "step_id": {
+ "type": "string"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "ts": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "type": {
+ "const": "gc.store.disk_critical",
+ "type": "string"
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/WorkflowEventProjection"
+ }
+ },
+ "required": [
+ "seq",
+ "type",
+ "ts",
+ "actor",
+ "payload"
+ ],
+ "title": "TypedEventStreamEnvelope gc.store.disk_critical",
+ "type": "object"
+ },
+ "TypedEventStreamEnvelopeGcStoreDiskWarn": {
+ "additionalProperties": false,
+ "properties": {
+ "actor": {
+ "type": "string"
+ },
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "message": {
+ "type": "string"
+ },
"payload": {
"$ref": "#/components/schemas/StoreDiskWarnPayload"
},
@@ -14341,6 +14691,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14392,6 +14748,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14443,6 +14805,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14494,6 +14862,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14545,6 +14919,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14596,6 +14976,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14647,6 +15033,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14698,6 +15090,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14749,6 +15147,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14800,6 +15204,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14851,6 +15261,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14902,6 +15318,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -14953,6 +15375,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15004,6 +15432,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15055,6 +15489,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15106,6 +15546,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15157,6 +15603,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15208,6 +15660,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15259,6 +15717,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15310,6 +15774,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15361,6 +15831,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15412,6 +15888,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15463,6 +15945,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15514,6 +16002,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15565,6 +16059,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15616,6 +16116,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15667,6 +16173,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15718,6 +16230,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15769,6 +16287,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15820,6 +16344,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15871,6 +16401,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15922,6 +16458,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -15973,6 +16515,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16024,6 +16572,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16075,6 +16629,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16126,6 +16686,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16177,6 +16743,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16228,6 +16800,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16279,6 +16857,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16330,6 +16914,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16381,6 +16971,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16432,6 +17028,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16483,6 +17085,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16534,6 +17142,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16585,6 +17199,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16636,6 +17256,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16687,6 +17313,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16738,6 +17370,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16789,6 +17427,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16840,6 +17484,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16891,6 +17541,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16942,6 +17598,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -16993,6 +17655,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17044,6 +17712,12 @@
"actor": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17116,6 +17790,8 @@
"emergency.acked": "#/components/schemas/TypedTaggedEventStreamEnvelopeEmergencyAcked",
"emergency.signaled": "#/components/schemas/TypedTaggedEventStreamEnvelopeEmergencySignaled",
"events.rotated": "#/components/schemas/TypedTaggedEventStreamEnvelopeEventsRotated",
+ "execution.step_defined": "#/components/schemas/TypedTaggedEventStreamEnvelopeExecutionStepDefined",
+ "execution.work_associated": "#/components/schemas/TypedTaggedEventStreamEnvelopeExecutionWorkAssociated",
"extmsg.adapter_added": "#/components/schemas/TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded",
"extmsg.adapter_removed": "#/components/schemas/TypedTaggedEventStreamEnvelopeExtmsgAdapterRemoved",
"extmsg.bound": "#/components/schemas/TypedTaggedEventStreamEnvelopeExtmsgBound",
@@ -17253,6 +17929,12 @@
{
"$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeEventsRotated"
},
+ {
+ "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeExecutionStepDefined"
+ },
+ {
+ "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeExecutionWorkAssociated"
+ },
{
"$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded"
},
@@ -17460,6 +18142,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17515,6 +18203,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17570,6 +18264,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17625,6 +18325,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17680,6 +18386,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17735,6 +18447,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17790,6 +18508,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17845,6 +18569,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17900,6 +18630,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -17955,6 +18691,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18010,6 +18752,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18065,6 +18813,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18120,6 +18874,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18175,6 +18935,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18230,6 +18996,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18285,6 +19057,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18340,6 +19118,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18395,6 +19179,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18450,6 +19240,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18505,6 +19301,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18557,6 +19359,8 @@
"bead.worktree.reap_skipped",
"bead.claim_rejected",
"bead.dead_assignee_reopened",
+ "execution.work_associated",
+ "execution.step_defined",
"mail.sent",
"mail.read",
"mail.archived",
@@ -18648,6 +19452,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18703,6 +19513,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18758,6 +19574,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18813,6 +19635,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18859,6 +19687,128 @@
"title": "TypedTaggedEventStreamEnvelope events.rotated",
"type": "object"
},
+ "TypedTaggedEventStreamEnvelopeExecutionStepDefined": {
+ "additionalProperties": false,
+ "properties": {
+ "actor": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ },
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "message": {
+ "type": "string"
+ },
+ "payload": {
+ "$ref": "#/components/schemas/NoPayload"
+ },
+ "run_id": {
+ "type": "string"
+ },
+ "seq": {
+ "format": "int64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "step_id": {
+ "type": "string"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "ts": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "type": {
+ "const": "execution.step_defined",
+ "type": "string"
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/WorkflowEventProjection"
+ }
+ },
+ "required": [
+ "seq",
+ "type",
+ "ts",
+ "actor",
+ "payload",
+ "city"
+ ],
+ "title": "TypedTaggedEventStreamEnvelope execution.step_defined",
+ "type": "object"
+ },
+ "TypedTaggedEventStreamEnvelopeExecutionWorkAssociated": {
+ "additionalProperties": false,
+ "properties": {
+ "actor": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ },
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "message": {
+ "type": "string"
+ },
+ "payload": {
+ "$ref": "#/components/schemas/NoPayload"
+ },
+ "run_id": {
+ "type": "string"
+ },
+ "seq": {
+ "format": "int64",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "session_id": {
+ "type": "string"
+ },
+ "step_id": {
+ "type": "string"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "ts": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "type": {
+ "const": "execution.work_associated",
+ "type": "string"
+ },
+ "workflow": {
+ "$ref": "#/components/schemas/WorkflowEventProjection"
+ }
+ },
+ "required": [
+ "seq",
+ "type",
+ "ts",
+ "actor",
+ "payload",
+ "city"
+ ],
+ "title": "TypedTaggedEventStreamEnvelope execution.work_associated",
+ "type": "object"
+ },
"TypedTaggedEventStreamEnvelopeExtmsgAdapterAdded": {
"additionalProperties": false,
"properties": {
@@ -18868,6 +19818,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18923,6 +19879,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -18978,6 +19940,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19033,6 +20001,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19088,6 +20062,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19143,6 +20123,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19198,6 +20184,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19253,6 +20245,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19308,6 +20306,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19363,6 +20367,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19418,6 +20428,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19473,6 +20489,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19528,6 +20550,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19583,6 +20611,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19638,6 +20672,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19693,6 +20733,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19748,6 +20794,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19803,6 +20855,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19858,6 +20916,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19913,6 +20977,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -19968,6 +21038,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20023,6 +21099,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20078,6 +21160,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20133,6 +21221,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20188,6 +21282,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20243,6 +21343,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20298,6 +21404,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20353,6 +21465,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20408,6 +21526,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20463,6 +21587,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20518,6 +21648,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20573,6 +21709,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20628,6 +21770,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20683,6 +21831,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20738,6 +21892,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20793,6 +21953,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20848,6 +22014,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20903,6 +22075,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -20958,6 +22136,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21013,6 +22197,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21068,6 +22258,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21123,6 +22319,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21178,6 +22380,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21233,6 +22441,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21288,6 +22502,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21343,6 +22563,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21398,6 +22624,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21453,6 +22685,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21508,6 +22746,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21563,6 +22807,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21618,6 +22868,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21673,6 +22929,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21728,6 +22990,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21783,6 +23051,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21838,6 +23112,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21893,6 +23173,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -21948,6 +23234,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22003,6 +23295,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22058,6 +23356,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22113,6 +23417,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22168,6 +23478,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22223,6 +23539,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22278,6 +23600,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
@@ -22333,6 +23661,12 @@
"city": {
"type": "string"
},
+ "depends_on_step_ids": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"message": {
"type": "string"
},
diff --git a/internal/api/orders_feed.go b/internal/api/orders_feed.go
index 65ddca43ea..74866d833a 100644
--- a/internal/api/orders_feed.go
+++ b/internal/api/orders_feed.go
@@ -1,6 +1,7 @@
package api
import (
+ "fmt"
"log"
"sort"
"strconv"
@@ -289,12 +290,49 @@ func buildWorkflowRunProjectionsRootOnly(state State, requestedScopeKind, reques
}, nil
}
+// activeWorkflowProjectionStatuses are the bead statuses that count as active
+// work for workflow projection and spawn selection, in read order. It is an
+// allowlist, so a status this fork does not recognize is treated as inactive
+// rather than spawned against.
+//
+// in_progress is read before open on purpose. The two reads are not a single
+// snapshot, so a bead that changes status between them can fall through both;
+// in this order the only flip that can be missed is open->in_progress, a bead
+// that was just claimed and so must not be spawned anyway. An in_progress->open
+// release is always caught by one of the two reads, and anything missed
+// reappears on the next patrol.
+var activeWorkflowProjectionStatuses = []string{"in_progress", "open"}
+
func listActiveWorkflowProjectionBeads(store beads.Store) ([]beads.Bead, error) {
- // Preserve the old ListOpen() semantics as a single active snapshot. A
- // union of separate open/in_progress queries can miss beads that change
- // status between reads, so this is one of the intentional raw scans until
- // ListQuery grows a multi-status selector.
- return store.List(beads.ListQuery{AllowScan: true})
+ // One Live, status-scoped read per active status, unioned by ID.
+ //
+ // The old raw scan could not gate status at all (gc-4zb): mapBdStatus folds
+ // bd's blocked/deferred/review/testing into Gas City's three statuses, so a
+ // scanned blocked root arrives with Status "open" and is indistinguishable
+ // from ready work. Filtering the snapshot on b.Status keeps every one of
+ // them for the same reason. Only the backing store filters on the raw
+ // status, by passing --status to bd, and only a Live query reaches it — a
+ // cached read matches on the collapsed status.
+ //
+ // This matters because the workflow-root spawn path selects on gc.routed_to
+ // without re-checking status: a blocked root that still carries a route is
+ // spawned against and burns a polecat slot on a no-op drain (gc-nz5i).
+ seen := make(map[string]struct{})
+ var active []beads.Bead
+ for _, status := range activeWorkflowProjectionStatuses {
+ items, err := store.List(beads.ListQuery{Status: status, AllowScan: true, Live: true})
+ if err != nil {
+ return nil, fmt.Errorf("listing %s workflow projection beads: %w", status, err)
+ }
+ for _, b := range items {
+ if _, dup := seen[b.ID]; dup {
+ continue
+ }
+ seen[b.ID] = struct{}{}
+ active = append(active, b)
+ }
+ }
+ return active, nil
}
func buildOrderRunFeedItems(state State, requestedScopeKind, requestedScopeRef string) (orderRunFeedResult, error) {
diff --git a/internal/api/orders_feed_test.go b/internal/api/orders_feed_test.go
index 1d5ba96d1f..225822cba4 100644
--- a/internal/api/orders_feed_test.go
+++ b/internal/api/orders_feed_test.go
@@ -232,3 +232,99 @@ func (s *workflowProjectionStore) List(query beads.ListQuery) ([]beads.Bead, err
}
return s.MemStore.List(query)
}
+
+// collapsedStatusProjectionStore models the production read path for the
+// workflow projection. A non-Live read (the raw scan, or any cached read)
+// returns blocked and deferred beads indistinguishable from ready work:
+// mapBdStatus folds bd's blocked/deferred/review/testing into Gas City's
+// "open", and CachingStore.List matches on that already-collapsed status. Only
+// the backing store filters on the raw status, by passing --status to bd, and
+// only a Live query reaches it.
+type collapsedStatusProjectionStore struct {
+ beads.Store
+ rawScan []beads.Bead // non-Live: blocked rows present, collapsed to "open"
+ liveByStatus map[string][]beads.Bead // Live: bd filtered on the raw status
+ liveStatuses []string
+}
+
+func (s *collapsedStatusProjectionStore) List(q beads.ListQuery) ([]beads.Bead, error) {
+ if !q.Live {
+ return append([]beads.Bead(nil), s.rawScan...), nil
+ }
+ s.liveStatuses = append(s.liveStatuses, q.Status)
+ return append([]beads.Bead(nil), s.liveByStatus[q.Status]...), nil
+}
+
+// TestListActiveWorkflowProjectionBeadsExcludesBlocked covers the read side of
+// gc-4zb. The workflow-root spawn path selects on gc.routed_to without
+// re-checking status, so a blocked root that reaches this projection while
+// still carrying a route is spawned against and burns a polecat slot on a no-op
+// drain.
+//
+// Live reproduction (gc-nz5i, root gc-27xf, step mol-do-work.do-work):
+// dolt_history_issues shows status=blocked while gc.routed_to stayed
+// /home/ds/gascity/polecat from 04:00:21 to 04:08:17, and the bead's own
+// reroute_observed records a second slot burned against it while blocked. It
+// carries no gc.run_target, so the writer-side restore cannot re-stamp it —
+// this is the reader, not the writer.
+//
+// Filtering the scan on b.Status cannot fix it: the blocked bead's Status is
+// already the collapsed "open", so it satisfies an {open, in_progress}
+// allowlist. The gate has to be a status-scoped Live read that lets bd filter
+// on the raw status.
+func TestListActiveWorkflowProjectionBeadsExcludesBlocked(t *testing.T) {
+ const route = "/home/ds/gascity/polecat"
+ // Blocked in bd, but every non-Live read decodes it as "open".
+ blocked := beads.Bead{
+ ID: "gc-nz5i", Title: "do-work", Type: "task", Status: "open",
+ Metadata: map[string]string{"gc.routed_to": route},
+ }
+ ready := beads.Bead{
+ ID: "gc-ready", Title: "ready", Type: "task", Status: "open",
+ Metadata: map[string]string{"gc.routed_to": route},
+ }
+ claimed := beads.Bead{
+ ID: "gc-claimed", Title: "claimed", Type: "task", Status: "in_progress",
+ Assignee: route + "/th-abc", Metadata: map[string]string{"gc.run_target": route},
+ }
+
+ store := &collapsedStatusProjectionStore{
+ Store: beads.NewMemStoreFrom(0, nil, nil),
+ rawScan: []beads.Bead{blocked, ready, claimed},
+ liveByStatus: map[string][]beads.Bead{
+ // bd's --status filter sees the raw status; gc-nz5i is blocked and absent.
+ "open": {ready},
+ "in_progress": {claimed},
+ },
+ }
+
+ got, err := listActiveWorkflowProjectionBeads(store)
+ if err != nil {
+ t.Fatalf("listActiveWorkflowProjectionBeads: %v", err)
+ }
+ ids := make(map[string]bool, len(got))
+ for _, b := range got {
+ ids[b.ID] = true
+ }
+ if ids["gc-nz5i"] {
+ t.Errorf("blocked bead gc-nz5i reached the workflow projection; the spawn path routes on its gc.routed_to and burns a slot")
+ }
+ // The gate must not shrink the projection to open-only: in_progress work is
+ // active and drives the running-run view.
+ if !ids["gc-ready"] {
+ t.Errorf("open routed bead gc-ready missing from projection")
+ }
+ if !ids["gc-claimed"] {
+ t.Errorf("in_progress bead gc-claimed missing from projection")
+ }
+ if len(got) != 2 {
+ t.Errorf("projection size = %d, want 2 (gc-ready, gc-claimed); got %v", len(got), ids)
+ }
+ // Every read must be Live and status-scoped, and in_progress must be read
+ // before open: the two reads are not one snapshot, so this order confines
+ // the missable flip to open->in_progress (a bead just claimed, which must
+ // not be spawned against anyway).
+ if want := strings.Join([]string{"in_progress", "open"}, ","); strings.Join(store.liveStatuses, ",") != want {
+ t.Errorf("live status reads = %v, want [in_progress open] (status-scoped, in_progress first)", store.liveStatuses)
+ }
+}
diff --git a/internal/api/response_cache_test.go b/internal/api/response_cache_test.go
index 817ea386fd..ca0fd614a6 100644
--- a/internal/api/response_cache_test.go
+++ b/internal/api/response_cache_test.go
@@ -218,6 +218,13 @@ func TestHandleAgentListCachesUntilIndexChanges(t *testing.T) {
}
}
+// listCallsPerFeedBuild is how many store List calls one workflow-projection
+// build costs: listActiveWorkflowProjectionBeads issues one Live,
+// status-scoped read per active status, because bd is the only reader that can
+// filter on the raw status (gc-4zb). These tests assert how often the feed
+// rebuilds, so they count builds in reads rather than pinning a literal.
+var listCallsPerFeedBuild = len(activeWorkflowProjectionStatuses)
+
func TestHandleOrdersFeedCachesUntilIndexChanges(t *testing.T) {
state := newFakeState(t)
rigStore := &countingStore{Store: beads.NewMemStore()}
@@ -257,8 +264,8 @@ func TestHandleOrdersFeedCachesUntilIndexChanges(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("second feed = %d, want 200", rec.Code)
}
- if rigStore.listCalls != 1 {
- t.Fatalf("rig List calls after cached repeat = %d, want 1", rigStore.listCalls)
+ if rigStore.listCalls != listCallsPerFeedBuild {
+ t.Fatalf("rig List calls after cached repeat = %d, want %d (one build)", rigStore.listCalls, listCallsPerFeedBuild)
}
if cityStore.listByLabelCalls != 1 {
t.Fatalf("city ListByLabel calls after cached repeat = %d, want 1", cityStore.listByLabelCalls)
@@ -270,8 +277,8 @@ func TestHandleOrdersFeedCachesUntilIndexChanges(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("third feed = %d, want 200", rec.Code)
}
- if rigStore.listCalls != 2 {
- t.Fatalf("rig List calls after index change = %d, want 2", rigStore.listCalls)
+ if want := 2 * listCallsPerFeedBuild; rigStore.listCalls != want {
+ t.Fatalf("rig List calls after index change = %d, want %d (two builds)", rigStore.listCalls, want)
}
if cityStore.listByLabelCalls != 2 {
t.Fatalf("city ListByLabel calls after index change = %d, want 2", cityStore.listByLabelCalls)
@@ -321,8 +328,8 @@ func TestHandleFormulaFeedCachesAcrossIndexChanges(t *testing.T) {
t.Fatalf("feed #%d = %d, want 200", i, rec.Code)
}
}
- if rigStore.listCalls != 1 {
- t.Fatalf("rig List calls after cached repeat = %d, want 1", rigStore.listCalls)
+ if rigStore.listCalls != listCallsPerFeedBuild {
+ t.Fatalf("rig List calls after cached repeat = %d, want %d (one build)", rigStore.listCalls, listCallsPerFeedBuild)
}
// A moving event sequence — the busy-city scenario from #3208 — must
@@ -335,8 +342,8 @@ func TestHandleFormulaFeedCachesAcrossIndexChanges(t *testing.T) {
t.Fatalf("feed after event %d = %d, want 200", i, rec.Code)
}
}
- if rigStore.listCalls != 1 {
- t.Fatalf("rig List calls across index churn = %d, want 1 (feed must key on time bucket)", rigStore.listCalls)
+ if rigStore.listCalls != listCallsPerFeedBuild {
+ t.Fatalf("rig List calls across index churn = %d, want %d (one build; feed must key on time bucket)", rigStore.listCalls, listCallsPerFeedBuild)
}
}
diff --git a/internal/api/store_health.go b/internal/api/store_health.go
index 7018ab0769..cc2df756df 100644
--- a/internal/api/store_health.go
+++ b/internal/api/store_health.go
@@ -94,7 +94,10 @@ func (s *Server) computeStoreHealth(ctx context.Context) (*StatusStoreHealth, er
return nil, err
}
lastAt, lastStatus := storehealth.LastMaintenance(s.state.EventProvider())
- h := storehealth.Compute(cityPath, size, rows, lastAt, lastStatus)
+ // countBeadStoreRows returns an error (handled above) rather than a
+ // fabricated count on every failure path, so rows here is always a
+ // real measurement.
+ h := storehealth.Compute(cityPath, size, rows, true, lastAt, lastStatus)
return statusStoreHealthFromDomain(h), nil
}
diff --git a/internal/beadmeta/hold_labels.go b/internal/beadmeta/hold_labels.go
new file mode 100644
index 0000000000..721f7ae670
--- /dev/null
+++ b/internal/beadmeta/hold_labels.go
@@ -0,0 +1,21 @@
+package beadmeta
+
+// HoldMayorLabel and HoldExternalLabel are the two canonical hold:
+// bd label values (engdocs/contributors/hold-label-conventions.md,
+// ga-tug8ry.1): "the required next actor is the mayor" and "the required
+// next actor or condition is outside this bd instance's control",
+// respectively. They are bd label *values* (data a bead carries in its
+// Labels []string), not role names — a role-neutral dispatcher checks for
+// their presence without knowing or caring who "mayor" is (ga-5736js).
+const (
+ HoldMayorLabel = "hold:mayor"
+ HoldExternalLabel = "hold:external"
+)
+
+// DispatchHoldLabels is the complete set of hold label values that must
+// exclude a bead from route-scoped, unassigned automatic dispatch (Tier 3
+// pool-demand queries and the control dispatcher's routed/run-target
+// tiers). Assignee-scoped queries (Tier 1 crash recovery, Tier 2 assigned-
+// ready) are hold-transparent by design and must never filter on this list
+// (ga-5736js).
+var DispatchHoldLabels = []string{HoldMayorLabel, HoldExternalLabel}
diff --git a/internal/beadmeta/hold_labels_test.go b/internal/beadmeta/hold_labels_test.go
new file mode 100644
index 0000000000..53a5e6bca3
--- /dev/null
+++ b/internal/beadmeta/hold_labels_test.go
@@ -0,0 +1,26 @@
+package beadmeta
+
+import "testing"
+
+// TestDispatchHoldLabelsMatchCanonicalHoldValues pins beadmeta as the single
+// named home for the two canonical hold values documented in
+// engdocs/contributors/hold-label-conventions.md (hold:mayor, hold:external)
+// so internal/config and cmd/gc can share one definition instead of each
+// re-spelling the label strings (ga-x9kptu / ga-5736js).
+func TestDispatchHoldLabelsMatchCanonicalHoldValues(t *testing.T) {
+ if HoldMayorLabel != "hold:mayor" {
+ t.Fatalf("HoldMayorLabel = %q, want %q", HoldMayorLabel, "hold:mayor")
+ }
+ if HoldExternalLabel != "hold:external" {
+ t.Fatalf("HoldExternalLabel = %q, want %q", HoldExternalLabel, "hold:external")
+ }
+ want := []string{HoldMayorLabel, HoldExternalLabel}
+ if len(DispatchHoldLabels) != len(want) {
+ t.Fatalf("DispatchHoldLabels = %#v, want %#v", DispatchHoldLabels, want)
+ }
+ for i, v := range want {
+ if DispatchHoldLabels[i] != v {
+ t.Fatalf("DispatchHoldLabels[%d] = %q, want %q", i, DispatchHoldLabels[i], v)
+ }
+ }
+}
diff --git a/internal/beadmeta/keys.go b/internal/beadmeta/keys.go
index cfcdcef5c3..b2d73b2d96 100644
--- a/internal/beadmeta/keys.go
+++ b/internal/beadmeta/keys.go
@@ -126,6 +126,7 @@ const (
MaxAttemptsMetadataKey = "gc.max_attempts"
MissingRootBeadIDMetadataKey = "gc.missing_root_bead_id"
ModelMetadataKey = "gc.model"
+ NativeStepDependenciesMetadataKey = "gc.native_step_dependencies.v1"
NextAttemptMetadataKey = "gc.next_attempt"
OnExhaustedMetadataKey = "gc.on_exhausted"
OnFailMetadataKey = "gc.on_fail"
@@ -373,6 +374,7 @@ var KnownMetadataKeys = []string{
MaxAttemptsMetadataKey,
MissingRootBeadIDMetadataKey,
ModelMetadataKey,
+ NativeStepDependenciesMetadataKey,
NextAttemptMetadataKey,
OnExhaustedMetadataKey,
OnFailMetadataKey,
diff --git a/internal/beads/beadstest/conformance.go b/internal/beads/beadstest/conformance.go
index a3881650e7..d6590ffe5e 100644
--- a/internal/beads/beadstest/conformance.go
+++ b/internal/beads/beadstest/conformance.go
@@ -497,6 +497,89 @@ func RunStoreTestsWithOptions(t *testing.T, newStore func() beads.Store, opts Op
}
})
+ // UpdateRoundTripsEveryDocumentedField pins the whole update wire, not just
+ // the description. Each field is written on its own so a backend that drops
+ // exactly one of them fails on that field rather than hiding behind the
+ // others. Update{Type} in particular had no coverage anywhere in the suite,
+ // which is how a store could silently ignore it.
+ t.Run("UpdateRoundTripsEveryDocumentedField", func(t *testing.T) {
+ s := newStore()
+ parent, err := s.Create(beads.Bead{Title: "parent"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := s.Create(beads.Bead{Title: "original", Type: "task", Labels: []string{"keep", "drop"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ title, status, typ, desc, assignee := "renamed", "in_progress", "gate", "new description", "worker-1"
+ // Not 2: backends normalize the default priority back to "unset".
+ priority := 1
+ // A slice, not a map: update order is part of what is being pinned, so
+ // a future field whose result depends on a prior one fails
+ // deterministically instead of flaking on map iteration order.
+ for _, u := range []struct {
+ name string
+ opts beads.UpdateOpts
+ }{
+ {"title", beads.UpdateOpts{Title: &title}},
+ {"status", beads.UpdateOpts{Status: &status}},
+ {"type", beads.UpdateOpts{Type: &typ}},
+ {"priority", beads.UpdateOpts{Priority: &priority}},
+ {"description", beads.UpdateOpts{Description: &desc}},
+ {"assignee", beads.UpdateOpts{Assignee: &assignee}},
+ {"parent_id", beads.UpdateOpts{ParentID: &parent.ID}},
+ {"labels", beads.UpdateOpts{Labels: []string{"added"}}},
+ {"metadata", beads.UpdateOpts{Metadata: map[string]string{"note": "x"}}},
+ } {
+ if err := s.Update(b.ID, u.opts); err != nil {
+ t.Fatalf("Update(%s): %v", u.name, err)
+ }
+ }
+
+ got, err := s.Get(b.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct{ field, got, want string }{
+ {"Title", got.Title, title},
+ {"Status", got.Status, status},
+ {"Type", got.Type, typ},
+ {"Description", got.Description, desc},
+ {"Assignee", got.Assignee, assignee},
+ {"ParentID", got.ParentID, parent.ID},
+ } {
+ if tc.got != tc.want {
+ t.Errorf("%s = %q, want %q", tc.field, tc.got, tc.want)
+ }
+ }
+ if got.Priority == nil || *got.Priority != priority {
+ t.Errorf("Priority = %v, want %d", got.Priority, priority)
+ }
+ if got.Metadata["note"] != "x" {
+ t.Errorf("Metadata[note] = %q, want %q", got.Metadata["note"], "x")
+ }
+ if !hasLabel(got.Labels, "added") {
+ t.Errorf("Labels = %v, want to contain %q (labels append)", got.Labels, "added")
+ }
+
+ // remove_labels is the one field that needs a second read to observe.
+ if err := s.Update(b.ID, beads.UpdateOpts{RemoveLabels: []string{"drop"}}); err != nil {
+ t.Fatalf("Update(remove_labels): %v", err)
+ }
+ got, err = s.Get(b.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hasLabel(got.Labels, "drop") {
+ t.Errorf("Labels = %v, want %q removed", got.Labels, "drop")
+ }
+ if !hasLabel(got.Labels, "keep") {
+ t.Errorf("Labels = %v, want %q preserved", got.Labels, "keep")
+ }
+ })
+
t.Run("UpdateNotFound", func(t *testing.T) {
s := newStore()
desc := "whatever"
@@ -1254,3 +1337,13 @@ func hasExactly(sorted []string, want ...string) bool {
}
return true
}
+
+// hasLabel reports whether labels contains want.
+func hasLabel(labels []string, want string) bool {
+ for _, l := range labels {
+ if l == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/beads/boundary_test.go b/internal/beads/boundary_test.go
index 262143294f..37a639356f 100644
--- a/internal/beads/boundary_test.go
+++ b/internal/beads/boundary_test.go
@@ -52,9 +52,16 @@ func findBdExecViolations(root string) ([]string, error) {
if base == ".git" || base == "vendor" || base == ".claude" || base == ".gc" || strings.HasPrefix(base, ".beads-src") {
return filepath.SkipDir
}
- // Skip git worktrees embedded in the repo (have a .git file, not dir).
- if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
- return filepath.SkipDir
+ // Skip git worktrees embedded in the repo (have a .git file, not
+ // dir) — but never apply this to root itself. gc agent sessions run
+ // from inside a worktree, so root legitimately has a .git file
+ // rather than a .git directory; skipping on that condition here
+ // would SkipDir the walk's very first entry and silently visit
+ // zero files.
+ if path != root {
+ if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
+ return filepath.SkipDir
+ }
}
// Skip nested Go modules: any directory other than root that owns
// its own go.mod is a separate module's source tree (a module-cache
@@ -195,6 +202,38 @@ func TestFindBdExecViolationsSkipsNestedGoModules(t *testing.T) {
}
}
+// TestFindBdExecViolationsScansWorktreeRoot pins the fix for ga-vpcbsa: every
+// gc agent session runs from a worktree under .gc/worktrees/, where
+// root/.git is a FILE (a `gitdir:` pointer), not a directory.
+// filepath.Walk invokes the callback on root first, so without a
+// `path != root` guard around the .git-file SkipDir check, the walk returns
+// filepath.SkipDir on entry zero and visits zero files — the invariant
+// passes vacuously instead of actually scanning anything.
+func TestFindBdExecViolationsScansWorktreeRoot(t *testing.T) {
+ root := t.TempDir()
+
+ mustWriteFile(t, filepath.Join(root, "go.mod"), "module example.com/fixture\n")
+
+ // Simulate a git worktree checkout: root's .git is a FILE, not a dir.
+ mustWriteFile(t, filepath.Join(root, ".git"), "gitdir: /nowhere\n")
+
+ // A real violation, directly in the checkout, outside any allowed dir.
+ mustWriteFile(t, filepath.Join(root, "cmd", "gc", "example.go"),
+ "package main\n\nfunc run() { exec.Command(\"bd\", \"prime\") }\n")
+
+ violations, err := findBdExecViolations(root)
+ if err != nil {
+ t.Fatalf("findBdExecViolations: %v", err)
+ }
+
+ if len(violations) != 1 {
+ t.Fatalf("violations = %v, want exactly 1 (root's .git file must not stop the walk)", violations)
+ }
+ if !strings.Contains(violations[0], filepath.Join("cmd", "gc", "example.go")) {
+ t.Fatalf("violations[0] = %q, want the cmd/gc/example.go violation", violations[0])
+ }
+}
+
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
diff --git a/internal/beads/caching_store.go b/internal/beads/caching_store.go
index 8b33f818d5..5744835471 100644
--- a/internal/beads/caching_store.go
+++ b/internal/beads/caching_store.go
@@ -46,7 +46,7 @@ type CachingStore struct {
syncFailures int
circuitTripped bool
stats CacheStats
- onChange func(eventType, beadID, runID, sessionID, stepID string, payload json.RawMessage)
+ onChange func(eventType, beadID, runID, sessionID, stepID string, dependsOnStepIDs *[]string, payload json.RawMessage)
problemf func(string)
problemLog map[string]cacheProblemLogState
@@ -163,7 +163,6 @@ const (
cacheReconcileIntervalMedium = 60 * time.Second
cacheReconcileIntervalLarge = 120 * time.Second
cacheProblemLogWindow = time.Minute
- cacheReconcileFailureBackoff = time.Minute
cacheReconcileBaseBackoff = 2 * time.Second
cacheReconcileMaxBackoff = 10 * time.Minute
// cacheReconcileSuccessLogWindow rate-limits the per-reconcile success
@@ -248,7 +247,7 @@ func computeAutoStagger(agentID string) time.Duration {
// changed bead's metadata at the record site (see notifyChange); the wiring
// stamps them onto the recorded event so the redacted export can forward them
// as typed primitives without ever decoding the payload.
-func NewCachingStore(backing Store, onChange func(eventType, beadID, runID, sessionID, stepID string, payload json.RawMessage)) *CachingStore {
+func NewCachingStore(backing Store, onChange func(eventType, beadID, runID, sessionID, stepID string, dependsOnStepIDs *[]string, payload json.RawMessage)) *CachingStore {
prefix := ""
bdBacking := false
nilBdBacking := false
@@ -276,7 +275,7 @@ func NewCachingStore(backing Store, onChange func(eventType, beadID, runID, sess
// NewCachingStoreForTest wraps any Store for testing without production prefix
// validation. It keeps the legacy 3-param onChange (tests do not exercise the
-// run/session ids); adaptLegacyOnChange bridges it to the production 5-param form.
+// typed correlation fields); adaptLegacyOnChange bridges it to production form.
func NewCachingStoreForTest(backing Store, onChange func(eventType, beadID string, payload json.RawMessage)) *CachingStore {
return newCachingStore(backing, "", adaptLegacyOnChange(onChange))
}
@@ -290,11 +289,11 @@ func NewCachingStoreForTestWithPrefix(backing Store, idPrefix string, onChange f
// adaptLegacyOnChange bridges the legacy 3-param onChange used by the test
// constructors to the production 5-param form, dropping the run/session ids the
// tests do not exercise. Nil-safe.
-func adaptLegacyOnChange(fn func(eventType, beadID string, payload json.RawMessage)) func(eventType, beadID, runID, sessionID, stepID string, payload json.RawMessage) {
+func adaptLegacyOnChange(fn func(eventType, beadID string, payload json.RawMessage)) func(eventType, beadID, runID, sessionID, stepID string, dependsOnStepIDs *[]string, payload json.RawMessage) {
if fn == nil {
return nil
}
- return func(eventType, beadID string, _, _, _ string, payload json.RawMessage) {
+ return func(eventType, beadID string, _, _, _ string, _ *[]string, payload json.RawMessage) {
fn(eventType, beadID, payload)
}
}
@@ -306,7 +305,7 @@ func (c *CachingStore) SetPrimeRetryDelayForTest(fn func(attempt int) time.Durat
c.primeRetryDelay = fn
}
-func newCachingStore(backing Store, idPrefix string, onChange func(eventType, beadID, runID, sessionID, stepID string, payload json.RawMessage)) *CachingStore {
+func newCachingStore(backing Store, idPrefix string, onChange func(eventType, beadID, runID, sessionID, stepID string, dependsOnStepIDs *[]string, payload json.RawMessage)) *CachingStore {
return &CachingStore{
backing: backing,
idPrefix: normalizeIDPrefix(idPrefix),
diff --git a/internal/beads/caching_store_events.go b/internal/beads/caching_store_events.go
index 27960ad3da..1fac70dbc9 100644
--- a/internal/beads/caching_store_events.go
+++ b/internal/beads/caching_store_events.go
@@ -6,7 +6,9 @@ import (
"fmt"
"maps"
"slices"
+ "strings"
"time"
+ "unicode/utf8"
"github.com/gastownhall/gascity/internal/beadmeta"
)
@@ -662,14 +664,47 @@ func (c *CachingStore) notifyChange(eventType string, b Bead) {
// free-form metadata map. The run-chain (workflow_id || molecule_id ||
// gc.root_bead_id || bead.ID) always resolves to a non-empty id since b.ID is
// non-empty; session id is a direct, optional metadata read. Both are
- // safeRef-gated again at the export boundary.
+ // Run/session are safeRef-gated at the export boundary; native step topology
+ // retains its own established 256-byte domain there.
runID := beadmeta.ResolveRunID(b.Metadata, b.ID, "")
sessionID := b.Metadata[beadmeta.SessionIDMetadataKey]
- // step_id is the acting work bead the lifecycle event is about: a work/dispatch
- // bead carries its own gc.step_id, so a bead.created/closed on one stamps that
- // step. Non-work beads (sessions, mail, …) carry none → empty, omitted at export.
+ // step_id is the semantic native execution step carried explicitly by the
+ // lifecycle bead. Non-work beads (sessions, mail, …) carry none → omitted.
stepID := b.Metadata[beadmeta.StepIDMetadataKey]
- c.onChange(eventType, b.ID, runID, sessionID, stepID, payload)
+ c.onChange(eventType, b.ID, runID, sessionID, stepID, nativeStepDependencies(b.Metadata, stepID), payload)
+}
+
+// nativeStepDependencies returns the explicit, canonical native topology fact.
+// It never derives edges from physical bead dependencies or other mutable state:
+// absent/malformed metadata is UNKNOWN (nil), while a canonical [] is a known root.
+func nativeStepDependencies(metadata map[string]string, stepID string) *[]string {
+ if !validTopologyStepID(stepID) {
+ return nil
+ }
+ raw, ok := metadata[beadmeta.NativeStepDependenciesMetadataKey]
+ if !ok {
+ return nil
+ }
+ var dependencies []string
+ if err := json.Unmarshal([]byte(raw), &dependencies); err != nil || dependencies == nil {
+ return nil
+ }
+ previous := ""
+ for _, dependency := range dependencies {
+ if !validTopologyStepID(dependency) || dependency == stepID || (previous != "" && dependency <= previous) {
+ return nil
+ }
+ previous = dependency
+ }
+ canonical, err := json.Marshal(dependencies)
+ if err != nil || raw != string(canonical) {
+ return nil
+ }
+ return &dependencies
+}
+
+func validTopologyStepID(id string) bool {
+ return len(id) <= 256 && utf8.ValidString(id) && strings.TrimSpace(id) != ""
}
type cacheNotification struct {
diff --git a/internal/beads/caching_store_reconcile.go b/internal/beads/caching_store_reconcile.go
index 1a34c069c9..3a15f7a64f 100644
--- a/internal/beads/caching_store_reconcile.go
+++ b/internal/beads/caching_store_reconcile.go
@@ -695,6 +695,12 @@ func (c *CachingStore) orphanFenceIDsLocked(freshByID map[string]Bead) []string
// hold c.mu (write lock).
func (c *CachingStore) promoteLiveLocked() {
c.state = cacheLive
+ // Re-arm the one-shot circuit-breaker signal. promoteLiveLocked is the single
+ // live-promotion point — both prime() and the reconcile success paths route
+ // through it — so resetting here ensures a store that recovers via reconcile
+ // (not just prime) will fire the trip log again on a subsequent re-degrade.
+ // Without this, a flapping store emits the breaker signal at most once.
+ c.circuitTripped = false
}
// reconcileSuccessLogLocked composes the per-reconcile success log line
diff --git a/internal/beads/caching_store_reconcile_census_test.go b/internal/beads/caching_store_reconcile_census_test.go
index 4d84b9d4ec..3d3bdd9495 100644
--- a/internal/beads/caching_store_reconcile_census_test.go
+++ b/internal/beads/caching_store_reconcile_census_test.go
@@ -87,7 +87,8 @@ func TestMergeOracleFieldCoverage(t *testing.T) {
"beads": true, "deps": true, "depsComplete": true, "dirty": true,
"beadSeq": true, "localBeadAt": true, "deletedSeq": true, "state": true,
"lastFreshAt": true, "mutationSeq": true, "primePartialErr": true,
- "syncFailures": true, "stats": true, // stats compared field-wise below
+ "syncFailures": true, "circuitTripped": true,
+ "stats": true, // stats compared field-wise below
}
excludedStore := map[string]bool{
"backing": true, "idPrefix": true, "mu": true, "reconciling": true,
@@ -98,11 +99,15 @@ func TestMergeOracleFieldCoverage(t *testing.T) {
"stopped": true, "latencyWindow": true, "latencyDriverActive": true,
"applyEventBeforeCommitForTest": true,
// Fork resilience/read-path state, orthogonal to the reconcile bead-state
- // end-state the oracle compares: circuitTripped (breaker), availabilityGate
- // (backing-transport gate), unavailableSkipLogged (reconcile-skip log
- // dedupe), degradedReads (read-path counter of last-good-cache serves, not
- // a reconcile delta).
- "circuitTripped": true, "availabilityGate": true,
+ // end-state the oracle compares: availabilityGate (backing-transport
+ // gate), unavailableSkipLogged (reconcile-skip log dedupe), degradedReads
+ // (read-path counter of last-good-cache serves, not a reconcile delta).
+ //
+ // circuitTripped is deliberately NOT here: upstream #3379 made the
+ // one-shot breaker signal part of the compared reconcile end-state, and
+ // the merge left it in both sets. Upstream's classification wins — it is
+ // a reconcile delta, not read-path state.
+ "availabilityGate": true,
"unavailableSkipLogged": true, "degradedReads": true,
}
assertFieldsClassified(t, reflect.TypeOf(CachingStore{}), comparedStore, excludedStore)
diff --git a/internal/beads/caching_store_reconcile_differential_test.go b/internal/beads/caching_store_reconcile_differential_test.go
index 40c6226927..8244b3f9fe 100644
--- a/internal/beads/caching_store_reconcile_differential_test.go
+++ b/internal/beads/caching_store_reconcile_differential_test.go
@@ -75,18 +75,19 @@ func (in snapshotInputs) quiescent(st storeState) bool {
// It captures every field the seam writes; the field-coverage census
// (TestMergeOracleFieldCoverage) proves this list stays exhaustive.
type mergeEndState struct {
- beads map[string]Bead
- deps map[string][]Dep
- depsComplete bool
- dirty map[string]struct{}
- beadSeq map[string]uint64
- localBeadAt map[string]time.Time
- deletedSeq map[string]uint64
- state cacheState
- lastFreshAt time.Time
- mutationSeq uint64
- primeErr string
- syncFailures int
+ beads map[string]Bead
+ deps map[string][]Dep
+ depsComplete bool
+ dirty map[string]struct{}
+ beadSeq map[string]uint64
+ localBeadAt map[string]time.Time
+ deletedSeq map[string]uint64
+ state cacheState
+ lastFreshAt time.Time
+ mutationSeq uint64
+ primeErr string
+ syncFailures int
+ circuitTripped bool
// stats fields the seam writes.
statsAdds int64
statsRemoves int64
@@ -214,6 +215,11 @@ func (b *countingBacking) List(q ListQuery) ([]Bead, error) {
// type assertion (no call), so a stray call would panic — a louder failure
// than a count mismatch. The store starts cacheLive (promoteLiveLocked
// overwrites it regardless).
+//
+// circuitTripped starts true — the one pre-merge value the seam must clear.
+// Seeding the zero value instead would make the end-state comparison of that
+// field vacuous (false on every implementation, every case), so a branch that
+// stopped re-arming the breaker would slip through the differential.
func newMergeHarnessStore(st storeState) (*CachingStore, *countingBacking) {
var counter *countingBacking
var backing Store
@@ -235,6 +241,8 @@ func newMergeHarnessStore(st storeState) (*CachingStore, *countingBacking) {
deletedSeq: cloneU64Map(st.deletedSeq),
mutationSeq: st.mutationSeq,
state: cacheLive,
+
+ circuitTripped: true,
}
ensureMaps(c)
return c, counter
@@ -281,6 +289,7 @@ func captureEndState(c *CachingStore) mergeEndState {
mutationSeq: c.mutationSeq,
primeErr: primeErr,
syncFailures: c.syncFailures,
+ circuitTripped: c.circuitTripped,
statsAdds: c.stats.Adds,
statsRemoves: c.stats.Removes,
statsUpdates: c.stats.Updates,
diff --git a/internal/beads/caching_store_reconcile_diffutil_test.go b/internal/beads/caching_store_reconcile_diffutil_test.go
index bca1500df9..f7af94f36a 100644
--- a/internal/beads/caching_store_reconcile_diffutil_test.go
+++ b/internal/beads/caching_store_reconcile_diffutil_test.go
@@ -38,6 +38,9 @@ func diffEndStates(want, got mergeEndState) string {
if want.syncFailures != got.syncFailures {
fmt.Fprintf(&b, " syncFailures: want=%v got=%v\n", want.syncFailures, got.syncFailures)
}
+ if want.circuitTripped != got.circuitTripped {
+ fmt.Fprintf(&b, " circuitTripped: want=%v got=%v\n", want.circuitTripped, got.circuitTripped)
+ }
if want.statsAdds != got.statsAdds {
fmt.Fprintf(&b, " stats.Adds: want=%v got=%v\n", want.statsAdds, got.statsAdds)
}
diff --git a/internal/beads/caching_store_reconcile_internal_test.go b/internal/beads/caching_store_reconcile_internal_test.go
index 17b93b843f..20ff79a18b 100644
--- a/internal/beads/caching_store_reconcile_internal_test.go
+++ b/internal/beads/caching_store_reconcile_internal_test.go
@@ -585,8 +585,8 @@ func TestRunReconciliation_CircuitTripLogs_OnLiveToDegraded(t *testing.T) {
tripCount++
}
}
- if tripCount == 0 {
- t.Fatal("expected 'circuit-breaker tripped' in log after live→degraded transition, got none")
+ if tripCount != 1 {
+ t.Fatalf("expected exactly one 'circuit-breaker tripped' log on the live→degraded transition, got %d", tripCount)
}
// Subsequent reconciliations in the degraded window must NOT re-emit the trip.
@@ -607,6 +607,69 @@ func TestRunReconciliation_CircuitTripLogs_OnLiveToDegraded(t *testing.T) {
}
}
+// TestRunReconciliation_CircuitTripReArmsAfterReconcileRecovery guards that the
+// one-shot breaker signal re-arms when a degraded store recovers via the
+// reconcile path (not just prime): trip → reconcile-recover → re-degrade must
+// fire the trip log a SECOND time. Without the circuitTripped reset in
+// promoteLiveLocked, a flapping store emits the signal at most once per process.
+func TestRunReconciliation_CircuitTripReArmsAfterReconcileRecovery(t *testing.T) {
+ backing := &failingScanStore{Store: NewMemStore()}
+ backing.setFailScan(true)
+ cs := NewCachingStoreForTest(backing, nil)
+ cs.state = cacheLive
+
+ var logMu sync.Mutex
+ var logLines []string
+ cs.problemf = func(msg string) {
+ logMu.Lock()
+ logLines = append(logLines, msg)
+ logMu.Unlock()
+ }
+ tripCount := func() int {
+ logMu.Lock()
+ defer logMu.Unlock()
+ n := 0
+ for _, l := range logLines {
+ if strings.Contains(l, "circuit-breaker tripped") {
+ n++
+ }
+ }
+ return n
+ }
+
+ // 1. Trip: drive live→degraded; the breaker fires once.
+ for i := 0; i < maxCacheSyncFailures; i++ {
+ cs.runReconciliation()
+ }
+ if cs.state != cacheDegraded {
+ t.Fatalf("state = %v, want cacheDegraded after the first failure run", cs.state)
+ }
+ if got := tripCount(); got != 1 {
+ t.Fatalf("trip count after first degrade = %d, want 1", got)
+ }
+
+ // 2. Recover via reconcile: a clean scan promotes degraded→live through
+ // promoteLiveLocked, which must re-arm the breaker.
+ backing.setFailScan(false)
+ cs.runReconciliation()
+ if cs.state != cacheLive {
+ t.Fatalf("state = %v, want cacheLive after the recovery reconcile", cs.state)
+ }
+
+ // 3. Re-degrade: the breaker must fire AGAIN, proving it re-armed on the
+ // reconcile recovery rather than staying latched from the first trip.
+ backing.setFailScan(true)
+ for i := 0; i < maxCacheSyncFailures; i++ {
+ cs.runReconciliation()
+ }
+ if cs.state != cacheDegraded {
+ t.Fatalf("state = %v, want cacheDegraded after the re-degrade run", cs.state)
+ }
+ if got := tripCount(); got != 2 {
+ t.Fatalf("trip count after recover→re-trip = %d, want 2 (breaker must re-arm on reconcile recovery)", got)
+ }
+}
+
// TestRunReconciliationPromotesPartialCacheToLive asserts that a clean
// full-scan reconciliation promotes a PrimeActive-only (cachePartial)
// cache to live. A reconcile loads the same complete active snapshot a
diff --git a/internal/beads/caching_store_runid_test.go b/internal/beads/caching_store_runid_test.go
index 7f7c16d791..ebfac46994 100644
--- a/internal/beads/caching_store_runid_test.go
+++ b/internal/beads/caching_store_runid_test.go
@@ -12,7 +12,7 @@ import (
// without ever decoding the payload.
func TestNotifyChangeResolvesRunSession(t *testing.T) {
var gotType, gotID, gotRun, gotSession, gotStep string
- cs := NewCachingStore(NewMemStore(), func(eventType, beadID, runID, sessionID, stepID string, _ json.RawMessage) {
+ cs := NewCachingStore(NewMemStore(), func(eventType, beadID, runID, sessionID, stepID string, _ *[]string, _ json.RawMessage) {
gotType, gotID, gotRun, gotSession, gotStep = eventType, beadID, runID, sessionID, stepID
})
@@ -38,7 +38,7 @@ func TestNotifyChangeResolvesRunSession(t *testing.T) {
// workflow_id wins the run-chain precedence over gc.root_bead_id.
var run2 string
- cs2 := NewCachingStore(NewMemStore(), func(_, _, runID, _, _ string, _ json.RawMessage) { run2 = runID })
+ cs2 := NewCachingStore(NewMemStore(), func(_, _, runID, _, _ string, _ *[]string, _ json.RawMessage) { run2 = runID })
cs2.notifyChange("bead.created", Bead{ID: "mc-2", Metadata: map[string]string{
"workflow_id": "wf-graph-root",
"gc.root_bead_id": "wf-root-x",
@@ -50,7 +50,7 @@ func TestNotifyChangeResolvesRunSession(t *testing.T) {
// No run-chain metadata: run falls back to the bead's own id; session + step empty
// (a non-work bead carries no gc.step_id).
var run3, sess3, step3 string
- cs3 := NewCachingStore(NewMemStore(), func(_, _, runID, sessionID, stepID string, _ json.RawMessage) {
+ cs3 := NewCachingStore(NewMemStore(), func(_, _, runID, sessionID, stepID string, _ *[]string, _ json.RawMessage) {
run3, sess3, step3 = runID, sessionID, stepID
})
cs3.notifyChange("bead.created", Bead{ID: "mc-3"})
diff --git a/internal/beads/contract/identity_test.go b/internal/beads/contract/identity_test.go
index 38e8abeae8..ae18fd2180 100644
--- a/internal/beads/contract/identity_test.go
+++ b/internal/beads/contract/identity_test.go
@@ -597,9 +597,16 @@ func TestNoExternalIdentityWriters(t *testing.T) {
if _, skip := skipDirs[d.Name()]; skip {
return filepath.SkipDir
}
- // Skip git worktrees embedded in the repo (have a .git file, not dir).
- if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
- return filepath.SkipDir
+ // Skip git worktrees embedded in the repo (have a .git file, not
+ // dir) — but never apply this to root itself. gc agent sessions run
+ // from inside a worktree, so root legitimately has a .git file
+ // rather than a .git directory; skipping on that condition here
+ // would SkipDir the walk's very first entry and silently visit
+ // zero files.
+ if path != root {
+ if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
+ return filepath.SkipDir
+ }
}
return nil
}
diff --git a/internal/beads/event_payload_contract_test.go b/internal/beads/event_payload_contract_test.go
index 95a5a012e9..258949bfb2 100644
--- a/internal/beads/event_payload_contract_test.go
+++ b/internal/beads/event_payload_contract_test.go
@@ -33,7 +33,7 @@ func TestNotifyChangePayloadDecodesViaSharedDecoder(t *testing.T) {
}
var got json.RawMessage
- cs := NewCachingStore(NewMemStore(), func(_, _, _, _, _ string, payload json.RawMessage) {
+ cs := NewCachingStore(NewMemStore(), func(_, _, _, _, _ string, _ *[]string, payload json.RawMessage) {
got = payload
})
cs.notifyChange("bead.created", seed)
diff --git a/internal/beads/exec/exec.go b/internal/beads/exec/exec.go
index 50e078d25e..ab3aaa546e 100644
--- a/internal/beads/exec/exec.go
+++ b/internal/beads/exec/exec.go
@@ -42,6 +42,19 @@ func (s *Store) SetEnv(env map[string]string) {
s.env = env
}
+// IDPrefix returns the bead ID prefix for this exec-backed scope, taken from
+// the projected GC_BEADS_PREFIX env. NewCachingStore uses this to key the
+// per-scope cache (owner metadata); without it an exec-backed rig store caches
+// as "(no-prefix)" and the reconciler's rig-scoped scale-check cannot associate
+// routed rig beads with the rig pool, so a direct `gc sling /` never
+// scales a worker.
+func (s *Store) IDPrefix() string {
+ if s == nil {
+ return ""
+ }
+ return strings.TrimSpace(s.env["GC_BEADS_PREFIX"])
+}
+
// NewStore returns a Store that delegates to the given script.
// The script path may be absolute, relative, or a bare name resolved via
// exec.LookPath.
diff --git a/internal/beads/exec/idprefix_test.go b/internal/beads/exec/idprefix_test.go
new file mode 100644
index 0000000000..0c6bd3decb
--- /dev/null
+++ b/internal/beads/exec/idprefix_test.go
@@ -0,0 +1,54 @@
+package exec //nolint:revive // internal package, always imported with alias
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+)
+
+// TestStoreIDPrefixFromEnv verifies the exec store exposes its scope prefix from
+// the projected GC_BEADS_PREFIX, including whitespace trimming and empty/nil env.
+func TestStoreIDPrefixFromEnv(t *testing.T) {
+ cases := []struct {
+ name string
+ env map[string]string
+ want string
+ }{
+ {name: "set", env: map[string]string{"GC_BEADS_PREFIX": "tr"}, want: "tr"},
+ {name: "trims whitespace", env: map[string]string{"GC_BEADS_PREFIX": " tr\n"}, want: "tr"},
+ {name: "empty value", env: map[string]string{"GC_BEADS_PREFIX": ""}, want: ""},
+ {name: "absent key", env: map[string]string{"GC_CITY": "x"}, want: ""},
+ {name: "nil env", env: nil, want: ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := NewStore("beads-provider")
+ s.SetEnv(tc.env)
+ if got := s.IDPrefix(); got != tc.want {
+ t.Fatalf("IDPrefix() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestStoreIDPrefixNilReceiver guards the nil-receiver path.
+func TestStoreIDPrefixNilReceiver(t *testing.T) {
+ var s *Store
+ if got := s.IDPrefix(); got != "" {
+ t.Fatalf("nil Store IDPrefix() = %q, want empty", got)
+ }
+}
+
+// TestCachingStoreDerivesPrefixFromExecStore is the regression this fix exists
+// for: NewCachingStore must pick up an exec-backed store's scope prefix via the
+// optional IDPrefix() capability, so a rig-scoped cache is keyed by prefix
+// rather than "(no-prefix)".
+func TestCachingStoreDerivesPrefixFromExecStore(t *testing.T) {
+ s := NewStore("beads-provider")
+ s.SetEnv(map[string]string{"GC_BEADS_PREFIX": "tr"})
+
+ cache := beads.NewCachingStore(s, nil)
+ if got := cache.IDPrefix(); got != "tr" {
+ t.Fatalf("NewCachingStore(execStore).IDPrefix() = %q, want %q", got, "tr")
+ }
+}
diff --git a/internal/beads/exec/testdata/conformance.sh b/internal/beads/exec/testdata/conformance.sh
index 266da4fc56..d5201661b7 100755
--- a/internal/beads/exec/testdata/conformance.sh
+++ b/internal/beads/exec/testdata/conformance.sh
@@ -158,11 +158,22 @@ update)
input=$(cat)
current=$(cat "$bead_file")
- # Apply description if present (non-null).
- has_desc=$(echo "$input" | jq 'has("description") and .description != null')
- if [ "$has_desc" = "true" ]; then
- new_desc=$(echo "$input" | jq -r '.description')
- current=$(echo "$current" | jq --arg d "$new_desc" '.description = $d')
+ # Apply the scalar string fields the update request may carry. Omitted
+ # fields are left unchanged.
+ for field in title status type description; do
+ has_field=$(echo "$input" | jq --arg f "$field" 'has($f) and .[$f] != null')
+ if [ "$has_field" = "true" ]; then
+ new_value=$(echo "$input" | jq -r --arg f "$field" '.[$f]')
+ current=$(echo "$current" | jq --arg f "$field" --arg v "$new_value" '.[$f] = $v')
+ fi
+ done
+
+ # Apply priority if present (non-null). Numeric, so it is not part of the
+ # string loop above.
+ has_priority=$(echo "$input" | jq 'has("priority") and .priority != null')
+ if [ "$has_priority" = "true" ]; then
+ new_priority=$(echo "$input" | jq '.priority')
+ current=$(echo "$current" | jq --argjson p "$new_priority" '.priority = $p')
fi
# Apply parent_id if present (non-null).
@@ -195,6 +206,12 @@ update)
current=$(echo "$current" | jq --argjson nl "$new_labels" '.labels = (.labels + $nl | unique)')
fi
+ # Remove labels if present.
+ drop_labels=$(echo "$input" | jq -c '.remove_labels // []')
+ if [ "$drop_labels" != "[]" ]; then
+ current=$(echo "$current" | jq --argjson dl "$drop_labels" '.labels = [.labels[] | select(. as $l | $dl | index($l) | not)]')
+ fi
+
echo "$current" >"$bead_file"
;;
diff --git a/internal/beads/native_step_topology_test.go b/internal/beads/native_step_topology_test.go
new file mode 100644
index 0000000000..8d9d40b7cd
--- /dev/null
+++ b/internal/beads/native_step_topology_test.go
@@ -0,0 +1,32 @@
+package beads
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+)
+
+func TestNativeStepDependenciesReadsOnlyCanonicalMetadata(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ metadata map[string]string
+ stepID string
+ want *[]string
+ }{
+ {name: "missing is unknown", stepID: "step-b"},
+ {name: "known root", stepID: "step-root", metadata: map[string]string{beadmeta.NativeStepDependenciesMetadataKey: "[]"}, want: ptr([]string{})},
+ {name: "canonical dependency list", stepID: "step-b", metadata: map[string]string{beadmeta.NativeStepDependenciesMetadataKey: `["step-a","step-c"]`}, want: ptr([]string{"step-a", "step-c"})},
+ {name: "noncanonical ordering is unknown", stepID: "step-c", metadata: map[string]string{beadmeta.NativeStepDependenciesMetadataKey: `["step-b","step-a"]`}},
+ {name: "self edge is unknown", stepID: "step-a", metadata: map[string]string{beadmeta.NativeStepDependenciesMetadataKey: `["step-a"]`}},
+ {name: "malformed is unknown", stepID: "step-b", metadata: map[string]string{beadmeta.NativeStepDependenciesMetadataKey: `not-json`}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := nativeStepDependencies(tc.metadata, tc.stepID); !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("nativeStepDependencies() = %#v, want %#v", got, tc.want)
+ }
+ })
+ }
+}
+
+func ptr(values []string) *[]string { return &values }
diff --git a/internal/beads/runview_roundtrip_test.go b/internal/beads/runview_roundtrip_test.go
index 91e8047e63..b2a1413bd2 100644
--- a/internal/beads/runview_roundtrip_test.go
+++ b/internal/beads/runview_roundtrip_test.go
@@ -90,17 +90,18 @@ func recordThroughNotifyChange(t *testing.T, seeds ...beadSeed) []events.Event {
t.Helper()
var out []events.Event
seq := uint64(0)
- cs := beads.NewCachingStore(beads.NewMemStore(), func(eventType, beadID, runID, sessionID, stepID string, payload json.RawMessage) {
+ cs := beads.NewCachingStore(beads.NewMemStore(), func(eventType, beadID, runID, sessionID, stepID string, dependsOnStepIDs *[]string, payload json.RawMessage) {
seq++
out = append(out, events.Event{
- Seq: seq,
- Type: eventType,
- Actor: "cache-reconcile",
- Subject: beadID,
- RunID: runID,
- SessionID: sessionID,
- StepID: stepID,
- Payload: payload,
+ Seq: seq,
+ Type: eventType,
+ Actor: "cache-reconcile",
+ Subject: beadID,
+ RunID: runID,
+ SessionID: sessionID,
+ StepID: stepID,
+ DependsOnStepIDs: dependsOnStepIDs,
+ Payload: payload,
})
})
for _, s := range seeds {
diff --git a/internal/bootstrap/packs/core/assets/scripts/reaper.sh b/internal/bootstrap/packs/core/assets/scripts/reaper.sh
index 333fd82a08..0015b6af52 100755
--- a/internal/bootstrap/packs/core/assets/scripts/reaper.sh
+++ b/internal/bootstrap/packs/core/assets/scripts/reaper.sh
@@ -1161,16 +1161,67 @@ if [ -d "$CITY_BEADS_DIR" ]; then
case "$SESSION_BEAD_PATTERN" in
*-*) SESSION_PRUNE_ANOMALY_SCOPE="${SESSION_BEAD_PATTERN%%-*}" ;;
esac
+
+ # Backup-age gate: skip bulk prune when no recent backup exists.
+ # Which state file decides freshness mirrors doctor's
+ # scanBackupFreshness: a scope with a registered Dolt destination is
+ # judged on its Dolt sync state, and only a scope that never migrated is
+ # judged on the legacy embedded-store state. `bd backup sync` writes
+ # only dolt-backup-state.json, so reading the legacy file on a migrated
+ # scope would latch this gate closed with no backup action able to clear it.
+ _PRUNE_MAX_AGE="${GC_REAPER_BACKUP_MAX_AGE:-${GC_BACKUP_MAX_AGE_FOR_BULK_DELETE:-86400}}"
+ case "$_PRUNE_MAX_AGE" in ''|*[!0-9]*) _PRUNE_MAX_AGE=86400 ;; esac
+ if [ -f "$CITY_BEADS_DIR/dolt-backup.json" ]; then
+ _BACKUP_STATE="$CITY_BEADS_DIR/dolt-backup-state.json"
+ _BACKUP_FIELD="last_sync"
+ else
+ _BACKUP_STATE="$CITY_BEADS_DIR/backup/backup_state.json"
+ _BACKUP_FIELD="timestamp"
+ fi
+ _PRUNE_SKIP=0
+ if [ ! -f "$_BACKUP_STATE" ]; then
+ record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "bulk prune skipped: backup stale or absent (source=$_BACKUP_STATE age=absent threshold=${_PRUNE_MAX_AGE}s)"
+ _PRUNE_SKIP=1
+ else
+ _BACKUP_TS=$(sed -n "s/.*\"$_BACKUP_FIELD\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$_BACKUP_STATE" | head -1)
+ if [ -z "$_BACKUP_TS" ]; then
+ record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "bulk prune skipped: backup stale or absent (source=$_BACKUP_STATE age=unparseable threshold=${_PRUNE_MAX_AGE}s)"
+ _PRUNE_SKIP=1
+ else
+ # Real on-disk timestamps are RFC3339Nano. Truncate to whole
+ # seconds, the same normalization Step 4's SQL does with
+ # SUBSTRING_INDEX(..., '.', 1).
+ case "$_BACKUP_TS" in *.*) _BACKUP_TS="${_BACKUP_TS%%.*}Z" ;; esac
+ _BACKUP_EPOCH=$(date -u -d "$_BACKUP_TS" '+%s' 2>/dev/null \
+ || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$_BACKUP_TS" '+%s' 2>/dev/null \
+ || python3 -c 'import datetime,calendar,sys; print(calendar.timegm(datetime.datetime.strptime(sys.argv[1],"%Y-%m-%dT%H:%M:%SZ").timetuple()))' "$_BACKUP_TS" 2>/dev/null \
+ || echo "")
+ _NOW_EPOCH=$(date -u '+%s')
+ if [ -z "$_BACKUP_EPOCH" ]; then
+ record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "bulk prune skipped: backup stale or absent (source=$_BACKUP_STATE age=unparseable threshold=${_PRUNE_MAX_AGE}s)"
+ _PRUNE_SKIP=1
+ else
+ _BACKUP_AGE=$(( _NOW_EPOCH - _BACKUP_EPOCH ))
+ if [ "$_BACKUP_AGE" -gt "$_PRUNE_MAX_AGE" ]; then
+ record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "bulk prune skipped: backup stale or absent (source=$_BACKUP_STATE age=${_BACKUP_AGE}s threshold=${_PRUNE_MAX_AGE}s)"
+ _PRUNE_SKIP=1
+ fi
+ fi
+ fi
+ fi
+
BD_PRUNE_ARGS=(prune --pattern "$SESSION_BEAD_PATTERN" --older-than "$SESSION_PURGE_AGE")
if [ -z "$DRY_RUN" ]; then BD_PRUNE_ARGS+=(--force); fi
BD_PRUNE_ARGS+=(--json)
- if PRUNE_JSON=$( ( cd "$CITY_ABS" && gc bd --city "$CITY_ABS" "${BD_PRUNE_ARGS[@]}" ) 2>/dev/null ); then :
- else PRUNE_JSON='{"pruned_count":0}'; fi
- PRUNE_COUNT=$(printf '%s' "$PRUNE_JSON" | sed -n 's/.*"pruned_count"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1)
- [ -z "$PRUNE_COUNT" ] && PRUNE_COUNT=0
- TOTAL_SESSIONS_PRUNED=$PRUNE_COUNT
- if [ "$PRUNE_COUNT" -gt 1000 ]; then
- record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "$PRUNE_COUNT closed session beads pruned (pattern=$SESSION_BEAD_PATTERN threshold: 1000)"
+ if [ "$_PRUNE_SKIP" -eq 0 ]; then
+ if PRUNE_JSON=$( ( cd "$CITY_ABS" && gc bd --city "$CITY_ABS" "${BD_PRUNE_ARGS[@]}" ) 2>/dev/null ); then :
+ else PRUNE_JSON='{"pruned_count":0}'; fi
+ PRUNE_COUNT=$(printf '%s' "$PRUNE_JSON" | sed -n 's/.*"pruned_count"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1)
+ [ -z "$PRUNE_COUNT" ] && PRUNE_COUNT=0
+ TOTAL_SESSIONS_PRUNED=$PRUNE_COUNT
+ if [ "$PRUNE_COUNT" -gt 1000 ]; then
+ record_anomaly "$SESSION_PRUNE_ANOMALY_SCOPE" "$PRUNE_COUNT closed session beads pruned (pattern=$SESSION_BEAD_PATTERN threshold: 1000)"
+ fi
fi
else
# ── type-safe SQL path (issue_type=session only) ──────────────────────
diff --git a/internal/bootstrap/packs/core/formulas/mol-do-work.toml b/internal/bootstrap/packs/core/formulas/mol-do-work.toml
index 08cdefdcd3..2c5b9351fa 100644
--- a/internal/bootstrap/packs/core/formulas/mol-do-work.toml
+++ b/internal/bootstrap/packs/core/formulas/mol-do-work.toml
@@ -142,8 +142,9 @@ Work is done. Close this drain step, then signal the controller to reclaim this
session:
```bash
-if [ -n "${GC_BEAD_ID:-}" ]; then
- gc bd update "$GC_BEAD_ID" --set-metadata gc.outcome=pass --status=closed --notes "Drain acknowledged."
+DRAIN_BEAD_ID="${GC_BEAD_ID:-${GC_TRIGGER_BEAD_ID:-}}"
+if [ -n "$DRAIN_BEAD_ID" ]; then
+ gc bd update "$DRAIN_BEAD_ID" --set-metadata gc.outcome=pass --status=closed --notes "Drain acknowledged."
fi
gc runtime drain-ack
```
diff --git a/internal/builtinpacks/registry.go b/internal/builtinpacks/registry.go
index 9623c422a5..0f3f001ba3 100644
--- a/internal/builtinpacks/registry.go
+++ b/internal/builtinpacks/registry.go
@@ -399,7 +399,7 @@ func SyntheticContentHash() (string, error) {
var entries []string
for _, layout := range syntheticPackLayouts() {
pack := layout.Pack
- manifest, err := manifestForFS(pack.FS)
+ manifest, err := manifestForPack(pack)
if err != nil {
return "", fmt.Errorf("hashing bundled pack %q: %w", pack.Name, err)
}
@@ -476,8 +476,16 @@ func materializeFS(src fs.FS, dst string) error {
return nil
}
+// validatePackFiles verifies a materialized pack against the embedded manifest:
+// every expected file present, with the expected mode and content.
+//
+// It does not walk dst looking for unexpected files. validateSyntheticRepoFileSet
+// already walks the whole cache once against the union of every layout's
+// manifest, and that union check strictly subsumes a per-pack one: a file
+// unexpected for its own pack is absent from the union too. Keeping both meant
+// about nine traversals of the same tree per call.
func validatePackFiles(pack Pack, dst string) error {
- manifest, err := manifestForFS(pack.FS)
+ manifest, err := manifestForPack(pack)
if err != nil {
return fmt.Errorf("reading bundled pack %q manifest: %w", pack.Name, err)
}
@@ -498,25 +506,6 @@ func validatePackFiles(pack Pack, dst string) error {
return fmt.Errorf("bundled pack cache %q file %s content differs from current binary", pack.Name, rel)
}
}
- if err := filepath.WalkDir(dst, func(path string, entry os.DirEntry, err error) error {
- if err != nil {
- return err
- }
- if entry.IsDir() {
- return nil
- }
- rel, err := filepath.Rel(dst, path)
- if err != nil {
- return err
- }
- rel = filepath.ToSlash(rel)
- if _, ok := manifest[rel]; !ok {
- return fmt.Errorf("bundled pack cache %q contains unexpected file %s", pack.Name, rel)
- }
- return nil
- }); err != nil {
- return fmt.Errorf("validating bundled pack cache %q file set: %w", pack.Name, err)
- }
return nil
}
@@ -563,12 +552,36 @@ func validateSyntheticRepoFileSet(dir string) error {
return nil
}
+// syntheticRepoAllowedPaths returns the file and directory sets a materialized
+// synthetic repo may contain.
+//
+// The result derives entirely from content embedded in the running binary, so it
+// is memoized for the process lifetime the same way syntheticContentHashOnce
+// memoizes the content hash. Rebuilding it per call re-walked every bundled
+// pack's embed.FS on every config load. Callers must treat the returned maps as
+// read-only.
func syntheticRepoAllowedPaths() (map[string]struct{}, map[string]struct{}, error) {
+ cached := syntheticRepoAllowedPathsOnce()
+ return cached.files, cached.dirs, cached.err
+}
+
+type syntheticRepoPathSets struct {
+ files map[string]struct{}
+ dirs map[string]struct{}
+ err error
+}
+
+var syntheticRepoAllowedPathsOnce = sync.OnceValue(func() syntheticRepoPathSets {
+ files, dirs, err := computeSyntheticRepoAllowedPaths()
+ return syntheticRepoPathSets{files: files, dirs: dirs, err: err}
+})
+
+func computeSyntheticRepoAllowedPaths() (map[string]struct{}, map[string]struct{}, error) {
files := map[string]struct{}{syntheticMarkerFile: {}}
dirs := make(map[string]struct{})
for _, layout := range syntheticPackLayouts() {
subpath := filepath.ToSlash(layout.Subpath)
- manifest, err := manifestForFS(layout.Pack.FS)
+ manifest, err := manifestForPack(layout.Pack)
if err != nil {
return nil, nil, fmt.Errorf("reading bundled pack %q manifest: %w", layout.Pack.Name, err)
}
@@ -583,6 +596,28 @@ func syntheticRepoAllowedPaths() (map[string]struct{}, map[string]struct{}, erro
return files, dirs, nil
}
+// manifestCache memoizes per-pack manifests by pack name. A pack's manifest is a
+// pure function of content embedded in the running binary, so it cannot change
+// within a process. Rebuilding it re-read every bundled file on every call.
+// Entries are read-only once stored.
+var manifestCache sync.Map
+
+type syntheticManifestResult struct {
+ manifest map[string]fileEntry
+ err error
+}
+
+// manifestForPack returns the memoized manifest for a bundled pack.
+func manifestForPack(pack Pack) (map[string]fileEntry, error) {
+ if cached, ok := manifestCache.Load(pack.Name); ok {
+ entry := cached.(syntheticManifestResult)
+ return entry.manifest, entry.err
+ }
+ manifest, err := manifestForFS(pack.FS)
+ manifestCache.Store(pack.Name, syntheticManifestResult{manifest: manifest, err: err})
+ return manifest, err
+}
+
func manifestForFS(src fs.FS) (map[string]fileEntry, error) {
manifest := make(map[string]fileEntry)
if err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error {
diff --git a/internal/builtinpacks/registry_test.go b/internal/builtinpacks/registry_test.go
index e18be2eb9d..a4c63c9d04 100644
--- a/internal/builtinpacks/registry_test.go
+++ b/internal/builtinpacks/registry_test.go
@@ -208,9 +208,16 @@ func TestMaterializeSyntheticRepoProductionCallersStayAllowlisted(t *testing.T)
case ".git", ".gc", "node_modules", "worktrees":
return filepath.SkipDir
}
- // Skip git worktrees embedded in the repo (have a .git file, not dir).
- if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
- return filepath.SkipDir
+ // Skip git worktrees embedded in the repo (have a .git file, not
+ // dir) — but never apply this to repoRoot itself. gc agent
+ // sessions run from inside a worktree, so repoRoot legitimately
+ // has a .git file rather than a .git directory; skipping on that
+ // condition here would SkipDir the walk's very first entry and
+ // silently visit zero files.
+ if path != repoRoot {
+ if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
+ return filepath.SkipDir
+ }
}
return nil
}
@@ -526,3 +533,84 @@ func TestSyntheticCacheKeyComponentMatchesContentHash(t *testing.T) {
t.Fatalf("SyntheticCacheKeyComponent not stable across calls: %q != %q", got, second)
}
}
+
+// TestValidateSyntheticRepoRejectsStrayFilesAnywhere pins the coverage that
+// justifies validatePackFiles no longer walking its own directory. The whole-tree
+// walk in validateSyntheticRepoFileSet checks every path against the union of all
+// layout manifests, which strictly subsumes a per-pack check: a file that is
+// unexpected for its own pack is absent from the union too. Nested layouts
+// (examples/bd contains examples/bd/dolt) are covered explicitly, because that is
+// the case where a per-pack and a union check could conceivably disagree.
+func TestValidateSyntheticRepoRejectsStrayFilesAnywhere(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ rel string
+ }{
+ {"pack root", "internal/bootstrap/packs/core/STRAY.txt"},
+ {"deep inside a pack", "internal/bootstrap/packs/core/assets/STRAY.txt"},
+ {"inside a nested pack", "examples/bd/dolt/STRAY.txt"},
+ {"in the parent of a nested pack", "examples/bd/STRAY.txt"},
+ {"cache root", "STRAY.txt"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dst := materializeTestRepo(t)
+ stray := filepath.Join(dst, filepath.FromSlash(tc.rel))
+ if err := os.MkdirAll(filepath.Dir(stray), 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ if err := os.WriteFile(stray, []byte("stray"), 0o644); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ if err := ValidateSyntheticRepo(dst, testCommit); err == nil {
+ t.Fatalf("ValidateSyntheticRepo accepted a stray file at %s", tc.rel)
+ }
+ })
+ }
+}
+
+// TestSyntheticRepoAllowedPathsIsStable pins that memoizing the allowed-path sets
+// does not change what they contain across calls.
+func TestSyntheticRepoAllowedPathsIsStable(t *testing.T) {
+ files1, dirs1, err := syntheticRepoAllowedPaths()
+ if err != nil {
+ t.Fatalf("syntheticRepoAllowedPaths: %v", err)
+ }
+ files2, dirs2, err := syntheticRepoAllowedPaths()
+ if err != nil {
+ t.Fatalf("syntheticRepoAllowedPaths (second call): %v", err)
+ }
+ if len(files1) != len(files2) || len(dirs1) != len(dirs2) {
+ t.Fatalf("allowed paths changed between calls: files %d/%d dirs %d/%d",
+ len(files1), len(files2), len(dirs1), len(dirs2))
+ }
+ if len(files1) == 0 {
+ t.Fatal("allowed file set is empty")
+ }
+}
+
+// TestManifestForPackMatchesUncached pins that the memoized per-pack manifest is
+// identical to a freshly built one.
+func TestManifestForPackMatchesUncached(t *testing.T) {
+ for _, pack := range All() {
+ cached, err := manifestForPack(pack)
+ if err != nil {
+ t.Fatalf("manifestForPack(%s): %v", pack.Name, err)
+ }
+ fresh, err := manifestForFS(pack.FS)
+ if err != nil {
+ t.Fatalf("manifestForFS(%s): %v", pack.Name, err)
+ }
+ if len(cached) != len(fresh) {
+ t.Fatalf("pack %s: memoized manifest has %d entries, fresh has %d", pack.Name, len(cached), len(fresh))
+ }
+ for rel, want := range fresh {
+ got, ok := cached[rel]
+ if !ok {
+ t.Fatalf("pack %s: memoized manifest missing %s", pack.Name, rel)
+ }
+ if got.perm != want.perm || !bytes.Equal(got.data, want.data) {
+ t.Fatalf("pack %s: memoized manifest differs for %s", pack.Name, rel)
+ }
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index ae07fa73ad..e92196577f 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -713,6 +713,9 @@ type AgentOverride struct {
// MaxSessionAgeJitter overrides the jitter added on top of MaxSessionAge.
// Duration string (e.g., "15m"). Empty disables jitter.
MaxSessionAgeJitter *string `toml:"max_session_age_jitter,omitempty"`
+ // AssignedWorkDeferLimit overrides Agent.AssignedWorkDeferLimit (see that
+ // field for semantics).
+ AssignedWorkDeferLimit *int `toml:"assigned_work_defer_limit,omitempty"`
// SleepAfterIdle overrides idle sleep policy for this agent. Accepts a
// duration string (e.g., "30s") or "off".
SleepAfterIdle *string `toml:"sleep_after_idle,omitempty"`
@@ -3350,9 +3353,11 @@ func (c *City) FormulasDir() string {
// AllPackDirs returns the union of city-level and all rig-level pack directories
// (city dirs first, then sorted-by-rig-name dirs), deduplicated. Use this for
-// global scans that intentionally need the full pack-fragment universe. Prompt
-// rendering for a specific rig should use PackDirsForRig so one rig's fragments
-// cannot override another rig's same-named fragments.
+// global scans that intentionally need the full pack-fragment universe, and as
+// the fallback PackDirsForRig("") uses for rig-less (scope="city") agents, which
+// have no single rig to scope to. Prompt rendering for a specific rig should use
+// PackDirsForRig so one rig's fragments cannot override another rig's
+// same-named fragments.
func (c *City) AllPackDirs() []string {
var dirs []string
dirs = appendUnique(dirs, c.PackDirs...)
@@ -3371,12 +3376,27 @@ func (c *City) AllPackDirs() []string {
// directories imported by rigName, deduplicated with city-level dirs kept first.
// Use this when rendering prompts for one agent so rig-imported template
// fragments are available without exposing fragments imported by other rigs.
+//
+// rigName == "" means a rig-less (scope="city") agent — e.g. deep-investigator,
+// supervisor, pack-author — which has no single rig to scope to. Those agents
+// fall back to AllPackDirs(): the union across every rig, sorted by rig name for
+// determinism. A fragment name defined identically in more than one rig's pack
+// resolves fine (that's the common case: a shared vocabulary like
+// handoff-routing, meant to render identically everywhere). A name defined with
+// DIFFERENT content in two rigs' packs silently picks whichever rig sorts LAST
+// alphabetically: renderPrompt parses pack dirs in order and a later
+// {{ define }} replaces an earlier one. For the same reason, a rig-imported
+// fragment can shadow a same-named city-level imported-pack fragment (city
+// dirs are parsed first) — city-ROOT fragments still win, they load last.
+// This is a pack-authoring collision this function does not detect.
+// See ga-bmjqvb.
func (c *City) PackDirsForRig(rigName string) []string {
+ if rigName == "" {
+ return c.AllPackDirs()
+ }
var dirs []string
dirs = appendUnique(dirs, c.PackDirs...)
- if rigName != "" {
- dirs = appendUnique(dirs, c.RigPackDirs[rigName]...)
- }
+ dirs = appendUnique(dirs, c.RigPackDirs[rigName]...)
return dirs
}
@@ -3681,6 +3701,19 @@ type Agent struct {
// disables jitter (every session restarts at exactly MaxSessionAge).
// Ignored when MaxSessionAge is unset.
MaxSessionAgeJitter string `toml:"max_session_age_jitter,omitempty"`
+ // AssignedWorkDeferLimit bounds how many consecutive reconciler ticks the
+ // idle-timeout ladder may defer on the same assigned-work bead
+ // (DecideIdleTimeout's AssignedWorkHas rung) before the reconciler
+ // overrides the defer and forces a stop via DecideAssignedWorkExhausted.
+ // Nil means use the built-in default. Without this backstop a session
+ // anchored to a bead that never clears assigned-work (e.g. a bead stuck
+ // open due to an upstream status-mapping bug) would defer indefinitely,
+ // reproducing the unbounded wake/idle-kill treadmill ga-3ox7rk fixed at
+ // the single-tick level. The counter resets whenever the anchor bead
+ // changes or the session is not idle-kill-eligible; see
+ // sessionHasAwakeAssignedWorkForReachableStore's caller in
+ // session_reconciler.go.
+ AssignedWorkDeferLimit *int `toml:"assigned_work_defer_limit,omitempty"`
// SleepAfterIdle overrides idle sleep policy for this agent. Accepts a
// duration string (e.g., "30s") or "off".
SleepAfterIdle string `toml:"sleep_after_idle,omitempty"`
@@ -3882,6 +3915,7 @@ func (a Agent) Clone() Agent {
out.ReadyDelayMs = copyIntPtr(a.ReadyDelayMs)
out.MaxActiveSessions = copyIntPtr(a.MaxActiveSessions)
out.MinActiveSessions = copyIntPtr(a.MinActiveSessions)
+ out.AssignedWorkDeferLimit = copyIntPtr(a.AssignedWorkDeferLimit)
out.EmitsPermissionWarning = copyBoolPtr(a.EmitsPermissionWarning)
out.HooksInstalled = copyBoolPtr(a.HooksInstalled)
out.InjectAssignedSkills = copyBoolPtr(a.InjectAssignedSkills)
@@ -4542,6 +4576,13 @@ func validateNamedSessions(cfg *City, requireBackingTemplate bool) (warnings []s
reservedSessionNames[sessionName] = identity
if s.ModeOrDefault() == "always" && agent != nil {
alwaysByTemplate[agent.QualifiedName()]++
+ if agent.EffectiveWakeMode() == "fresh" {
+ warnings = append(warnings, fmt.Sprintf(
+ "named_session %q: mode %q with wake_mode %q on template %q %s; use only for a deliberate restart-per-cycle actor",
+ s.QualifiedName(), s.ModeOrDefault(), agent.EffectiveWakeMode(), agent.QualifiedName(),
+ alwaysFreshWakeModeMarker,
+ ))
+ }
if maxActive := agent.EffectiveMaxActiveSessions(); maxActive != nil && *maxActive < alwaysByTemplate[agent.QualifiedName()] {
return nil, fmt.Errorf(
"named_session %q: mode %q exceeds max_active_sessions capacity %d on template %q",
@@ -4560,6 +4601,20 @@ func validateNamedSessions(cfg *City, requireBackingTemplate bool) (warnings []s
return warnings, nil
}
+// alwaysFreshWakeModeMarker is a stable substring on the warning emitted when a
+// mode="always" named session backs a wake_mode="fresh" template. CLI warning
+// classification keys off this marker, so keep it in sync with
+// IsAlwaysFreshWakeModeWarning.
+const alwaysFreshWakeModeMarker = "starts a fresh provider session after every drain"
+
+// IsAlwaysFreshWakeModeWarning reports whether a load warning is the non-fatal
+// always+fresh advisory. CLI warning filters use this to print the notice and
+// keep it non-fatal in strict mode. Keep in sync with
+// alwaysFreshWakeModeMarker.
+func IsAlwaysFreshWakeModeWarning(warning string) bool {
+ return strings.Contains(warning, alwaysFreshWakeModeMarker)
+}
+
// disabledNamedSessionMarker is a stable suffix on the warning emitted when a
// named session is skipped because its backing template did not resolve after
// pack expansion. CLI warning classification keys off this marker, so keep it
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 476c34c262..53f442113b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -1912,13 +1913,13 @@ func TestEffectiveWorkQueryDefault(t *testing.T) {
if strings.Contains(got, `--include-ephemeral`) {
t.Errorf("EffectiveWorkQuery() default must be bd 1.0.4-compatible without --include-ephemeral: %q", got)
}
- if !strings.Contains(got, `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20`) {
+ if !strings.Contains(got, `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20`) {
t.Errorf("EffectiveWorkQuery() missing tier 3 pool-demand probe: %q", got)
}
if !strings.Contains(got, "-- mayor") {
t.Errorf("EffectiveWorkQuery() missing tier 3 target argument: %q", got)
}
- if !strings.Contains(got, `bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20`) {
+ if !strings.Contains(got, `bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20`) {
t.Errorf("EffectiveWorkQuery() missing run_target migration fallback: %q", got)
}
for _, want := range []string{`.metadata`, `.[:1]`} {
@@ -1934,7 +1935,7 @@ func TestEffectiveWorkQueryDefault(t *testing.T) {
func TestEffectiveWorkQueryBD105CompatibilityOptIn(t *testing.T) {
a := Agent{Name: "mayor"}
got := a.EffectiveWorkQueryForBeads(BeadsConfig{BDCompatibility: BeadsBDCompatibility105})
- if !strings.Contains(got, `bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20`) {
+ if !strings.Contains(got, `bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20`) {
t.Errorf("EffectiveWorkQueryForBeads(bd-1.0.5) missing include-ephemeral routed probe: %q", got)
}
if !strings.Contains(got, `bd ready --include-ephemeral --assignee="$id" --json --limit=1`) {
@@ -2082,9 +2083,21 @@ case "$*" in
*) printf '[]' ;;
esac
`)
- if strings.TrimSpace(out) != `[{"id":"assigned-in-progress","ephemeral":true}]` {
+ // The row is compared field-wise rather than byte-wise: the in_progress
+ // tier now attaches a blocked_by array (empty here — the fake bd reports
+ // no dependencies) so the hook-side unready filter can see readiness state
+ // that `bd list` does not compute. What matters is that unblocked assigned
+ // work is still surfaced for crash recovery.
+ var gotRows []map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &gotRows); err != nil {
+ t.Fatalf("EffectiveAssignedInProgressQuery() output is not JSON: %v (%q)", err, out)
+ }
+ if len(gotRows) != 1 || gotRows[0]["id"] != "assigned-in-progress" {
t.Fatalf("EffectiveAssignedInProgressQuery() output = %q, want assigned in-progress work", out)
}
+ if _, ok := gotRows[0]["blocked_by"]; !ok {
+ t.Errorf("EffectiveAssignedInProgressQuery() row missing blocked_by: %q", out)
+ }
}
func TestEffectiveAssignedReadyQueryCustomPreservesOverride(t *testing.T) {
@@ -2346,7 +2359,7 @@ func TestEffectiveWorkQueryRoutedQueueUsesNativeHybridSortAcrossReadyTiers(t *te
}, `#!/bin/sh
set -eu
case "$*" in
- "ready --metadata-field gc.routed_to=hello-world/worker --unassigned --exclude-type=epic --json --sort hybrid --limit=20")
+ "ready --metadata-field gc.routed_to=hello-world/worker --unassigned --exclude-type=epic --exclude-label hold:mayor --exclude-label hold:external --json --sort hybrid --limit=20")
printf '[{"id":"first-routed","priority":2,"created_at":"2026-05-20T06:09:30Z","no_history":true}]'
;;
*)
@@ -2403,16 +2416,18 @@ func TestEffectiveWorkQueryRoutedQueueUsesHybridSortHonoringPriority(t *testing.
}
for _, tc := range cases {
// Canonical routed tier honors priority for fresh work via hybrid.
- if !strings.Contains(tc.got, `--unassigned --exclude-type=epic --json --sort hybrid --limit=20`) {
+ if !strings.Contains(tc.got, `--unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20`) {
t.Errorf("%s: routed tier must select bd --sort hybrid: %q", tc.name, tc.got)
}
// ...and must NOT revert to the priority-blind FIFO on the routed tier.
- if strings.Contains(tc.got, `gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort oldest`) {
+ // (The negative probe searched for `hybrid` rather than `oldest` before
+ // the v1.4.0 resync, so it could never fire; corrected here.)
+ if strings.Contains(tc.got, `gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest`) {
t.Errorf("%s: routed tier still uses priority-blind --sort oldest: %q", tc.name, tc.got)
}
// The retiring migration probe (ga-dhf44) deliberately stays --sort
// oldest; the fix does not touch workquery.go:54.
- if !strings.Contains(tc.got, `gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20`) {
+ if !strings.Contains(tc.got, `gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20`) {
t.Errorf("%s: migration probe must remain --sort oldest (unchanged): %q", tc.name, tc.got)
}
}
@@ -2487,7 +2502,7 @@ func TestEffectiveWorkQueryExcludesEpics(t *testing.T) {
// resume its own assigned ephemeral epic wisp (the patrol-loop pattern).
wantPresent := []string{
// routed/pool tier still excludes epics (gc-udx guard)
- `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json`,
+ `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json`,
// assigned tiers carry NO epic exclusion
`bd list --status in_progress --assignee="$id" --json`,
`bd ready --assignee="$id" --json`,
@@ -2513,7 +2528,7 @@ func TestEffectiveWorkQueryExcludesEpicsControlDispatcher(t *testing.T) {
a := Agent{Name: ControlDispatcherAgentName, Dir: "gascity"}
got := a.EffectiveWorkQuery()
wantPresent := []string{
- `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json`,
+ `bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json`,
`bd list --status in_progress --assignee="$cand" --json`,
`bd ready --assignee="$cand" --json`,
`-- gascity/control-dispatcher gascity/workflow-control`,
@@ -8381,6 +8396,31 @@ func TestPackDirsForRig(t *testing.T) {
}
}
+// TestPackDirsForRigEmptyRigNameFallsBackToAllPackDirs guards the scope="city"
+// agent fix: an empty rigName must resolve every rig's pack dirs via
+// AllPackDirs, not just the city-level ones, so city-scope agents (e.g.
+// deep-investigator, supervisor, pack-author) can see rig-imported fragments.
+func TestPackDirsForRigEmptyRigNameFallsBackToAllPackDirs(t *testing.T) {
+ c := &City{
+ PackDirs: []string{"/city/packs/a"},
+ RigPackDirs: map[string][]string{
+ "zulu": {"/rig/zulu/packs/z"},
+ "alpha": {"/rig/alpha/packs/x"},
+ },
+ }
+
+ got := c.PackDirsForRig("")
+ want := c.AllPackDirs()
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("PackDirsForRig(\"\") = %v, want AllPackDirs() = %v", got, want)
+ }
+
+ justCityDirs := []string{"/city/packs/a"}
+ if reflect.DeepEqual(got, justCityDirs) {
+ t.Fatalf("PackDirsForRig(\"\") = %v, regressed to city-only dirs (dropped RigPackDirs)", got)
+ }
+}
+
func TestDefaultInstallAgentHooksForProvider(t *testing.T) {
cases := []struct {
provider string
diff --git a/internal/config/field_sync_test.go b/internal/config/field_sync_test.go
index 235da6b123..25c438b7b1 100644
--- a/internal/config/field_sync_test.go
+++ b/internal/config/field_sync_test.go
@@ -187,6 +187,7 @@ func TestApplyAgentPatchCoversAllFields(t *testing.T) {
IdleTimeout: strVal("15m"),
MaxSessionAge: strVal("5h"),
MaxSessionAgeJitter: strVal("15m"),
+ AssignedWorkDeferLimit: intVal(3),
SleepAfterIdle: strVal("30s"),
InstallAgentHooks: []string{"claude"},
HooksInstalled: &trueVal,
@@ -343,6 +344,7 @@ func TestApplyAgentOverrideCoversAllFields(t *testing.T) {
IdleTimeout: strVal("15m"),
MaxSessionAge: strVal("5h"),
MaxSessionAgeJitter: strVal("15m"),
+ AssignedWorkDeferLimit: intVal(3),
SleepAfterIdle: strVal("30s"),
InstallAgentHooks: []string{"claude"},
HooksInstalled: &trueVal,
diff --git a/internal/config/options_test.go b/internal/config/options_test.go
index 93ed60f4bd..5c5d37f060 100644
--- a/internal/config/options_test.go
+++ b/internal/config/options_test.go
@@ -1242,3 +1242,68 @@ func schemaHasChoice(schema []ProviderOption, key, value string) bool {
}
return false
}
+
+// TestResolveClaudeCanonicalModelIDsThroughResolvers drives the real builtin
+// claude schema through both resolver entry points with the canonical provider
+// model IDs operators actually pin in agent.toml.
+//
+// This is the path that failed in ra-jbbv0, and the enum tests in
+// internal/worker/builtin do not reach it: they inspect the Choices table
+// directly, while the incident's two failure surfaces are both here.
+// ResolveExplicitOptions rejects an out-of-enum value outright ("invalid value
+// for model: claude-opus-5"), which left four named sessions unwakeable;
+// ResolveOptions instead finds no choice, skips the FlagArgs append behind its
+// choice != nil guard, and silently emits no --model at all, which left a whole
+// city running the provider default while `gc config show` still reported the
+// pin. Both are asserted here so a future edit to the enum cannot regress
+// either one unnoticed.
+func TestResolveClaudeCanonicalModelIDsThroughResolvers(t *testing.T) {
+ schema := BuiltinProviders()["claude"].OptionsSchema
+ if len(schema) == 0 {
+ t.Fatal("builtin claude provider has no OptionsSchema")
+ }
+
+ for _, model := range []string{
+ "claude-opus-5",
+ "claude-opus-5[1m]",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ } {
+ t.Run(model, func(t *testing.T) {
+ want := []string{"--model", model}
+
+ args, _, err := ResolveOptions(schema, map[string]string{"model": model}, nil)
+ if err != nil {
+ t.Fatalf("ResolveOptions(model=%q) error = %v, want nil", model, err)
+ }
+ if !containsArgPair(args, want) {
+ t.Errorf("ResolveOptions(model=%q) args = %v, want to contain %v", model, args, want)
+ }
+
+ explicit, err := ResolveExplicitOptions(schema, map[string]string{"model": model})
+ if err != nil {
+ t.Fatalf("ResolveExplicitOptions(model=%q) error = %v, want nil", model, err)
+ }
+ if !containsArgPair(explicit, want) {
+ t.Errorf("ResolveExplicitOptions(model=%q) args = %v, want to contain %v", model, explicit, want)
+ }
+ })
+ }
+}
+
+// containsArgPair reports whether args contains pair as an adjacent subsequence.
+func containsArgPair(args []string, pair []string) bool {
+ for i := 0; i+len(pair) <= len(args); i++ {
+ match := true
+ for j, want := range pair {
+ if args[i+j] != want {
+ match = false
+ break
+ }
+ }
+ if match {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/config/pack.go b/internal/config/pack.go
index e60887cdbd..1eca0b2492 100644
--- a/internal/config/pack.go
+++ b/internal/config/pack.go
@@ -2804,6 +2804,7 @@ func (ov *AgentOverride) toAgentPatch() *AgentPatch {
IdleTimeout: ov.IdleTimeout,
MaxSessionAge: ov.MaxSessionAge,
MaxSessionAgeJitter: ov.MaxSessionAgeJitter,
+ AssignedWorkDeferLimit: ov.AssignedWorkDeferLimit,
SleepAfterIdle: ov.SleepAfterIdle,
InstallAgentHooks: ov.InstallAgentHooks,
Skills: ov.Skills,
diff --git a/internal/config/patch.go b/internal/config/patch.go
index 5c738a3564..9eb4754a80 100644
--- a/internal/config/patch.go
+++ b/internal/config/patch.go
@@ -72,6 +72,9 @@ type AgentPatch struct {
MaxSessionAge *string `toml:"max_session_age,omitempty"`
// MaxSessionAgeJitter overrides the max session age jitter. Duration string (e.g., "15m").
MaxSessionAgeJitter *string `toml:"max_session_age_jitter,omitempty"`
+ // AssignedWorkDeferLimit overrides Agent.AssignedWorkDeferLimit (see that
+ // field for semantics).
+ AssignedWorkDeferLimit *int `toml:"assigned_work_defer_limit,omitempty"`
// SleepAfterIdle overrides idle sleep policy for this agent. Accepts a
// duration string or "off".
SleepAfterIdle *string `toml:"sleep_after_idle,omitempty"`
@@ -522,6 +525,9 @@ func applyAgentMutation(a *Agent, p *AgentPatch, sleepSource string) {
if p.MaxSessionAgeJitter != nil {
a.MaxSessionAgeJitter = *p.MaxSessionAgeJitter
}
+ if p.AssignedWorkDeferLimit != nil {
+ a.AssignedWorkDeferLimit = p.AssignedWorkDeferLimit
+ }
if p.SleepAfterIdle != nil {
a.SleepAfterIdle = NormalizeSleepAfterIdle(*p.SleepAfterIdle)
a.SleepAfterIdleSource = sleepSource
diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go
index 0fa9d2b91e..8adf0b8aa4 100644
--- a/internal/config/provider_test.go
+++ b/internal/config/provider_test.go
@@ -313,6 +313,9 @@ func TestBuiltinProvidersOpenCode(t *testing.T) {
if p.ReadyDelayMs != 8000 {
t.Errorf("ReadyDelayMs = %d, want 8000", p.ReadyDelayMs)
}
+ if p.AcceptStartupDialogs == nil || *p.AcceptStartupDialogs {
+ t.Errorf("AcceptStartupDialogs = %v, want false (OpenCode permissions are non-interactive)", p.AcceptStartupDialogs)
+ }
}
func TestBuiltinProvidersKiro(t *testing.T) {
diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go
index bf316641d5..303d005ebb 100644
--- a/internal/config/resolve_test.go
+++ b/internal/config/resolve_test.go
@@ -1804,6 +1804,27 @@ func TestResolveProviderBuiltinOpenCodeCustomCommandKeepsACPArgsOnCustomBinary(t
}
}
+func TestResolveProviderOpenCodeStartupDialogPolicyInheritedByWrapper(t *testing.T) {
+ base := "builtin:opencode"
+ agent := &Agent{Name: "worker", Provider: "wrapped-opencode"}
+ cityProviders := map[string]ProviderSpec{
+ "wrapped-opencode": {
+ Base: &base,
+ },
+ }
+
+ rp, err := ResolveProvider(agent, nil, cityProviders, lookPathOnly("opencode"))
+ if err != nil {
+ t.Fatalf("ResolveProvider: %v", err)
+ }
+ if rp.BuiltinAncestor != "opencode" {
+ t.Fatalf("BuiltinAncestor = %q, want opencode", rp.BuiltinAncestor)
+ }
+ if rp.AcceptStartupDialogs == nil || *rp.AcceptStartupDialogs {
+ t.Fatalf("AcceptStartupDialogs = %v, want false inherited from builtin opencode", rp.AcceptStartupDialogs)
+ }
+}
+
// --- Tri-state capability bool tests ---
//
// These verify the three-way *bool semantics for SupportsHooks,
diff --git a/internal/config/session_sleep_test.go b/internal/config/session_sleep_test.go
index 2918e08894..893f0b149b 100644
--- a/internal/config/session_sleep_test.go
+++ b/internal/config/session_sleep_test.go
@@ -231,6 +231,52 @@ func TestValidateNamedSessions_RejectsAlwaysWithSleepAfterIdle(t *testing.T) {
}
}
+func TestValidateNamedSessions_WarnsAlwaysWithFreshWakeMode(t *testing.T) {
+ tests := []struct {
+ name string
+ mode string
+ wakeMode string
+ wantWarn bool
+ }{
+ {name: "always fresh", mode: "always", wakeMode: "fresh", wantWarn: true},
+ {name: "always resume", mode: "always", wakeMode: "resume"},
+ {name: "on demand fresh", mode: "on_demand", wakeMode: "fresh"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &City{
+ Workspace: Workspace{Name: "test-city"},
+ Agents: []Agent{{
+ Name: "watchdog",
+ WakeMode: tt.wakeMode,
+ }},
+ NamedSessions: []NamedSession{{
+ Template: "watchdog",
+ Mode: tt.mode,
+ }},
+ }
+
+ warnings, err := ValidateNamedSessions(cfg)
+ if err != nil {
+ t.Fatalf("ValidateNamedSessions() error = %v, want nil", err)
+ }
+ if tt.wantWarn {
+ if len(warnings) != 1 {
+ t.Fatalf("ValidateNamedSessions() warnings = %v, want exactly one", warnings)
+ }
+ if !strings.Contains(warnings[0], `mode "always"`) ||
+ !strings.Contains(warnings[0], `wake_mode "fresh"`) {
+ t.Fatalf("warning = %q, want always/fresh configuration named", warnings[0])
+ }
+ return
+ }
+ if len(warnings) != 0 {
+ t.Fatalf("ValidateNamedSessions() warnings = %v, want none", warnings)
+ }
+ })
+ }
+}
+
func TestValidateNamedSessions_RejectsAliasSessionNameCollision(t *testing.T) {
cfg := &City{
Workspace: Workspace{
diff --git a/internal/config/testdata/workquery/legacy_AssignedInProgress_bd104.golden b/internal/config/testdata/workquery/legacy_AssignedInProgress_bd104.golden
index 0d2435a9d3..69e1701f70 100644
--- a/internal/config/testdata/workquery/legacy_AssignedInProgress_bd104.golden
+++ b/internal/config/testdata/workquery/legacy_AssignedInProgress_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_AssignedInProgress_bd105.golden b/internal/config/testdata/workquery/legacy_AssignedInProgress_bd105.golden
index 0d2435a9d3..69e1701f70 100644
--- a/internal/config/testdata/workquery/legacy_AssignedInProgress_bd105.golden
+++ b/internal/config/testdata/workquery/legacy_AssignedInProgress_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_PoolDemand_bd104.golden b/internal/config/testdata/workquery/legacy_PoolDemand_bd104.golden
index 8fbf546686..666f51be8e 100644
--- a/internal/config/testdata/workquery/legacy_PoolDemand_bd104.golden
+++ b/internal/config/testdata/workquery/legacy_PoolDemand_bd104.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- rig/control-dispatcher
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- rig/control-dispatcher
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_PoolDemand_bd105.golden b/internal/config/testdata/workquery/legacy_PoolDemand_bd105.golden
index b22a4132f6..b3b621c0ec 100644
--- a/internal/config/testdata/workquery/legacy_PoolDemand_bd105.golden
+++ b/internal/config/testdata/workquery/legacy_PoolDemand_bd105.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- rig/control-dispatcher
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- rig/control-dispatcher
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_RoutedPool_bd104.golden b/internal/config/testdata/workquery/legacy_RoutedPool_bd104.golden
index 9aaa3b89a8..165bd946c8 100644
--- a/internal/config/testdata/workquery/legacy_RoutedPool_bd104.golden
+++ b/internal/config/testdata/workquery/legacy_RoutedPool_bd104.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_RoutedPool_bd105.golden b/internal/config/testdata/workquery/legacy_RoutedPool_bd105.golden
index 1209aee0cd..2a0ec0da1b 100644
--- a/internal/config/testdata/workquery/legacy_RoutedPool_bd105.golden
+++ b/internal/config/testdata/workquery/legacy_RoutedPool_bd105.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_Work_bd104.golden b/internal/config/testdata/workquery/legacy_Work_bd104.golden
index d7ce7991c4..15f5405e2b 100644
--- a/internal/config/testdata/workquery/legacy_Work_bd104.golden
+++ b/internal/config/testdata/workquery/legacy_Work_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd ready --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd ready --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/legacy_Work_bd105.golden b/internal/config/testdata/workquery/legacy_Work_bd105.golden
index feb8e9a1be..2d84541510 100644
--- a/internal/config/testdata/workquery/legacy_Work_bd105.golden
+++ b/internal/config/testdata/workquery/legacy_Work_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd ready --include-ephemeral --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$cand" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; legacy=""; case "$id" in *control-dispatcher) legacy="${id%control-dispatcher}workflow-control";; esac; for cand in "$id" "$legacy"; do [ -z "$cand" ] && continue; r=$(bd ready --include-ephemeral --assignee="$cand" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; probe_pool_demand "$2"; printf "[]"' -- rig/control-dispatcher rig/workflow-control
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_AssignedInProgress_bd104.golden b/internal/config/testdata/workquery/normal_AssignedInProgress_bd104.golden
index 3989feb9bb..37635b261f 100644
--- a/internal/config/testdata/workquery/normal_AssignedInProgress_bd104.golden
+++ b/internal/config/testdata/workquery/normal_AssignedInProgress_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_AssignedInProgress_bd105.golden b/internal/config/testdata/workquery/normal_AssignedInProgress_bd105.golden
index 3989feb9bb..37635b261f 100644
--- a/internal/config/testdata/workquery/normal_AssignedInProgress_bd105.golden
+++ b/internal/config/testdata/workquery/normal_AssignedInProgress_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_PoolDemand_bd104.golden b/internal/config/testdata/workquery/normal_PoolDemand_bd104.golden
index 493b3d05b9..83a989d9ed 100644
--- a/internal/config/testdata/workquery/normal_PoolDemand_bd104.golden
+++ b/internal/config/testdata/workquery/normal_PoolDemand_bd104.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_PoolDemand_bd105.golden b/internal/config/testdata/workquery/normal_PoolDemand_bd105.golden
index 5c640f679b..7c1a9dfeaa 100644
--- a/internal/config/testdata/workquery/normal_PoolDemand_bd105.golden
+++ b/internal/config/testdata/workquery/normal_PoolDemand_bd105.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_RoutedPool_bd104.golden b/internal/config/testdata/workquery/normal_RoutedPool_bd104.golden
index 92ebb16d73..d69e65ffef 100644
--- a/internal/config/testdata/workquery/normal_RoutedPool_bd104.golden
+++ b/internal/config/testdata/workquery/normal_RoutedPool_bd104.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_RoutedPool_bd105.golden b/internal/config/testdata/workquery/normal_RoutedPool_bd105.golden
index 0bf9eaed44..9a35d066a6 100644
--- a/internal/config/testdata/workquery/normal_RoutedPool_bd105.golden
+++ b/internal/config/testdata/workquery/normal_RoutedPool_bd105.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_Work_bd104.golden b/internal/config/testdata/workquery/normal_Work_bd104.golden
index 5c34c06552..6b2212a823 100644
--- a/internal/config/testdata/workquery/normal_Work_bd104.golden
+++ b/internal/config/testdata/workquery/normal_Work_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/normal_Work_bd105.golden b/internal/config/testdata/workquery/normal_Work_bd105.golden
index 2d1c4f72f7..784ceda5cb 100644
--- a/internal/config/testdata/workquery/normal_Work_bd105.golden
+++ b/internal/config/testdata/workquery/normal_Work_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --include-ephemeral --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --include-ephemeral --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_AssignedInProgress_bd104.golden b/internal/config/testdata/workquery/pool_AssignedInProgress_bd104.golden
index 3989feb9bb..37635b261f 100644
--- a/internal/config/testdata/workquery/pool_AssignedInProgress_bd104.golden
+++ b/internal/config/testdata/workquery/pool_AssignedInProgress_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_AssignedInProgress_bd105.golden b/internal/config/testdata/workquery/pool_AssignedInProgress_bd105.golden
index 3989feb9bb..37635b261f 100644
--- a/internal/config/testdata/workquery/pool_AssignedInProgress_bd105.golden
+++ b/internal/config/testdata/workquery/pool_AssignedInProgress_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; printf "[]"'
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_PoolDemand_bd104.golden b/internal/config/testdata/workquery/pool_PoolDemand_bd104.golden
index e101764a55..7d6c7d8cd6 100644
--- a/internal/config/testdata/workquery/pool_PoolDemand_bd104.golden
+++ b/internal/config/testdata/workquery/pool_PoolDemand_bd104.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker-pool
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "")'\''; } || printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_PoolDemand_bd105.golden b/internal/config/testdata/workquery/pool_PoolDemand_bd105.golden
index 2032fb6299..a8748f1981 100644
--- a/internal/config/testdata/workquery/pool_PoolDemand_bd105.golden
+++ b/internal/config/testdata/workquery/pool_PoolDemand_bd105.golden
@@ -1 +1 @@
-sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker-pool
\ No newline at end of file
+sh -c 'target="$1"; ready_json=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --limit 0) || exit $?; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit 0) || exit $?; legacy_json=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")]'\'') || exit $?; legacy_ephemeral_json=$(printf "[]"); printf "%s\n%s\n%s\n" "$ready_json" "$legacy_json" "$legacy_ephemeral_json" | jq -s "(add // []) | unique_by(.id) | length"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_RoutedPool_bd104.golden b/internal/config/testdata/workquery/pool_RoutedPool_bd104.golden
index 7624248fdf..fb516b2390 100644
--- a/internal/config/testdata/workquery/pool_RoutedPool_bd104.golden
+++ b/internal/config/testdata/workquery/pool_RoutedPool_bd104.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_RoutedPool_bd105.golden b/internal/config/testdata/workquery/pool_RoutedPool_bd105.golden
index 1ef332e796..8bd04a43ec 100644
--- a/internal/config/testdata/workquery/pool_RoutedPool_bd105.golden
+++ b/internal/config/testdata/workquery/pool_RoutedPool_bd105.golden
@@ -1 +1 @@
-sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
+sh -c 'case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_Work_bd104.golden b/internal/config/testdata/workquery/pool_Work_bd104.golden
index 168ce473b4..df5ad71e0c 100644
--- a/internal/config/testdata/workquery/pool_Work_bd104.golden
+++ b/internal/config/testdata/workquery/pool_Work_bd104.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)] | sort_by(.created_at // "") | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$({ bd query --json '\''ephemeral=true AND status=open'\'' --limit=0 2>/dev/null | jq --arg target "$target" '\''[.[] | select((.assignee // "") == "") | select(((.metadata["gc.routed_to"] // "") == $target) or (((.metadata["gc.routed_to"] // "") == "") and ((.metadata["gc.run_target"] // "") == $target) and ((.metadata["gc.kind"] // "") == "workflow"))) | select(((.issue_type // .type // "") != "epic")) | select(([ (.dependencies // [])[] | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks")) | select((.status // .depends_on_status // "") != "closed") ] | length) == 0) | select(([ (.labels // [])[] | select(. == "hold:mayor" or . == "hold:external") ] | length) == 0)] | sort_by(.created_at // "") | .[:20]'\'' 2>/dev/null; } || printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/testdata/workquery/pool_Work_bd105.golden b/internal/config/testdata/workquery/pool_Work_bd105.golden
index 1eb55ca1c5..1c0a8ae6cd 100644
--- a/internal/config/testdata/workquery/pool_Work_bd105.golden
+++ b/internal/config/testdata/workquery/pool_Work_bd105.golden
@@ -1 +1 @@
-sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --include-ephemeral --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
+sh -c 'for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); if [ -n "$r" ] && [ "$r" != "[]" ]; then bid=$(printf "%s" "$r" | jq -r ".[0].id // empty" 2>/dev/null); bb="[]"; [ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | jq -c '\''[.[0].dependencies[]? | select(.dependency_type == "blocks" or .dependency_type == "waits-for" or .dependency_type == "conditional-blocks") | {id, status}]'\'' 2>/dev/null); [ -z "$bb" ] && bb="[]"; nblocked=$(printf "%s" "$bb" | jq -r '\''[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length'\'' 2>/dev/null); [ -z "$nblocked" ] && nblocked=0; if [ "$nblocked" = "0" ]; then r_enriched=$(printf "%s" "$r" | jq -c --argjson bb "$bb" '\''map(. + {blocked_by: $bb})'\'' 2>/dev/null); [ -n "$r_enriched" ] && [ "$r_enriched" != "[]" ] && r="$r_enriched"; printf "%s" "$r" && exit 0; fi; fi; r=$(bd query --json '\''ephemeral=true AND status=in_progress'\'' --limit=0 2>/dev/null | jq --arg id "$id" '\''[.[] | select((.assignee // "") == $id)] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do [ -z "$id" ] && continue; r=$(bd ready --include-ephemeral --assignee="$id" --json --limit=1 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; done; case "$GC_SESSION_ORIGIN" in ephemeral|"") ;; *) exit 0 ;; esac; probe_pool_demand() { target="$1"; [ -z "$target" ] && return 1; r=$(bd ready --include-ephemeral --metadata-field "gc.routed_to=$target" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort hybrid --limit=20 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_candidates=$(bd ready --include-ephemeral --metadata-field "gc.run_target=$target" --metadata-field "gc.kind=workflow" --unassigned --exclude-type=epic --exclude-label "hold:mayor" --exclude-label "hold:external" --json --sort oldest --limit=20 2>/dev/null); r=$(printf "%s" "$legacy_candidates" | jq '\''[.[] | select((.metadata["gc.routed_to"] // "") == "")] | .[:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; legacy_ephemeral_candidates=$(printf "[]"); r=$(printf "%s" "$legacy_ephemeral_candidates" | jq '\''.[0:1]'\'' 2>/dev/null); [ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; return 1; }; probe_pool_demand "$1"; printf "[]"' -- worker-pool
\ No newline at end of file
diff --git a/internal/config/workquery.go b/internal/config/workquery.go
index e06a92db41..e124bafb5b 100644
--- a/internal/config/workquery.go
+++ b/internal/config/workquery.go
@@ -30,6 +30,32 @@ func bdReadyIncludeEphemeralArg(includeEphemeralReady bool) string {
return ""
}
+// excludeHoldLabelsShellArgs renders a repeated --exclude-label flag for
+// every beadmeta.DispatchHoldLabels value, so route-scoped, unassigned
+// pool-demand queries never surface a bead intentionally parked on a
+// dispatch hold (ga-x9kptu / ga-5736js). Assignee-scoped tiers (Tier 1/2)
+// must stay hold-transparent by design and must never call this.
+func excludeHoldLabelsShellArgs() string {
+ var args string
+ for _, label := range beadmeta.DispatchHoldLabels {
+ args += ` --exclude-label "` + label + `"`
+ }
+ return args
+}
+
+// excludeHoldLabelsJQClause returns a jq select(...) clause dropping beads
+// that carry any beadmeta.DispatchHoldLabels value, for jq-based pool-demand
+// filters that have no bd-side --exclude-label flag to lean on. Mirrors the
+// bracketed-count style of the dependency-blocking select above it so both
+// clauses read the same way (ga-x9kptu / ga-5736js).
+func excludeHoldLabelsJQClause() string {
+ conds := make([]string, len(beadmeta.DispatchHoldLabels))
+ for i, label := range beadmeta.DispatchHoldLabels {
+ conds[i] = `. == "` + label + `"`
+ }
+ return ` | select(([ (.labels // [])[] | select(` + strings.Join(conds, " or ") + `) ] | length) == 0)`
+}
+
// jqMeta renders the jq expression that reads a bead-metadata key with an
// empty-string default, e.g. (.metadata["gc.routed_to"] // ""). Shell/jq
// builders use it so embedded key spellings stay anchored to the beadmeta
@@ -39,7 +65,7 @@ func jqMeta(key string) string {
}
func bdReadyPoolDemandShell(limitFlag string, includeEphemeralReady bool) string {
- return `bd ready` + bdReadyIncludeEphemeralArg(includeEphemeralReady) + ` --metadata-field "` + beadmeta.RoutedToMetadataKey + `=$target" --unassigned --exclude-type=epic --json ` + limitFlag
+ return `bd ready` + bdReadyIncludeEphemeralArg(includeEphemeralReady) + ` --metadata-field "` + beadmeta.RoutedToMetadataKey + `=$target" --unassigned --exclude-type=epic` + excludeHoldLabelsShellArgs() + ` --json ` + limitFlag
}
// bdReadyPoolDemandMigrationShell is a temporary raw compatibility probe for
@@ -51,7 +77,7 @@ func bdReadyPoolDemandShell(limitFlag string, includeEphemeralReady bool) string
// requires jq in the default worker/reconciler environment; remove it with the
// Go-side legacy candidates after the backfill completion tracked by ga-dhf44.
func bdReadyPoolDemandMigrationShell(limitFlag string, includeEphemeralReady bool) string {
- return `bd ready` + bdReadyIncludeEphemeralArg(includeEphemeralReady) + ` --metadata-field "` + beadmeta.RunTargetMetadataKey + `=$target" --metadata-field "` + beadmeta.KindMetadataKey + `=` + beadmeta.KindWorkflow + `" --unassigned --exclude-type=epic --json --sort oldest ` + limitFlag
+ return `bd ready` + bdReadyIncludeEphemeralArg(includeEphemeralReady) + ` --metadata-field "` + beadmeta.RunTargetMetadataKey + `=$target" --metadata-field "` + beadmeta.KindMetadataKey + `=` + beadmeta.KindWorkflow + `" --unassigned --exclude-type=epic` + excludeHoldLabelsShellArgs() + ` --json --sort oldest ` + limitFlag
}
func poolDemandMigrationFilterJQ(limit int) string {
@@ -70,13 +96,16 @@ func bdQueryEphemeralStatusQuietShell(status string) string {
return bdQueryEphemeralStatusShell(status) + ` 2>/dev/null`
}
-func legacyEphemeralReadyFilterJQ(selector string, limit int) string {
- filter := `[.[] | ` + selector +
+func legacyEphemeralReadyFilterJQ(selector string, limit int, excludeHoldLabels bool) string {
+ body := selector +
` | select(((.issue_type // .type // "") != "epic"))` +
` | select(([ (.dependencies // [])[]` +
` | select((.type // .dep_type // "") as $t | ($t == "blocks" or $t == "waits-for" or $t == "conditional-blocks"))` +
- ` | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)]` +
- ` | sort_by(.created_at // "")`
+ ` | select((.status // .depends_on_status // "") != "closed") ] | length) == 0)`
+ if excludeHoldLabels {
+ body += excludeHoldLabelsJQClause()
+ }
+ filter := `[.[] | ` + body + `]` + ` | sort_by(.created_at // "")`
if limit > 0 {
filter += ` | .[:` + strconv.Itoa(limit) + `]`
}
@@ -91,6 +120,7 @@ func legacyEphemeralPoolDemandShell(limit int, includeEphemeralReady, quiet bool
`select((.assignee // "") == "")`+
` | select((`+jqMeta(beadmeta.RoutedToMetadataKey)+` == $target) or ((`+jqMeta(beadmeta.RoutedToMetadataKey)+` == "") and (`+jqMeta(beadmeta.RunTargetMetadataKey)+` == $target) and (`+jqMeta(beadmeta.KindMetadataKey)+` == "`+beadmeta.KindWorkflow+`")))`,
limit,
+ true,
)
query := bdQueryEphemeralStatusShell("open")
if quiet {
@@ -184,11 +214,69 @@ func standardAssignedInProgressWorkQueryScript(includeEphemeralReady bool) strin
return `for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do ` +
`[ -z "$id" ] && continue; ` +
`r=$(bd list --status in_progress --assignee="$id" --json --limit=1 2>/dev/null); ` +
- `[ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; ` +
+ `if [ -n "$r" ] && [ "$r" != "[]" ]; then ` +
+ inProgressBlockedByEnrichmentScript("r") +
+ `fi; ` +
ephemeralAssignedInProgressProbeScript("id", includeEphemeralReady) +
`done; `
}
+// inProgressBlockedByEnrichmentScript hardens the in_progress "crash recovery"
+// work-query tier against re-serving a bead that cannot progress.
+//
+// `bd list --status in_progress` does no readiness computation: unlike
+// `bd ready` it emits neither blocked_by nor is_blocked. That makes the
+// defensive hook-side filter (filterUnreadyHookCandidates ->
+// isDepBlockedHookCandidate) a structural no-op for this tier, because an
+// absent blocked_by is correctly read as "not blocked". A step that is
+// in_progress + assigned but held by an open gate or an unclosed blocking
+// dependency is therefore re-served on every hook tick, forever.
+//
+// `bd ready` cannot be substituted here: it excludes in_progress by design,
+// so it would return nothing and defeat crash recovery entirely. Instead we
+// read the candidate's own dependency rows and attach the blocked_by array
+// the rest of the pipeline already knows how to interpret. When the candidate
+// is blocked we skip it and fall through to the ready-gated tier, so a session
+// holding one blocked step can still be served its other ready assigned work.
+//
+// Only ready-blocking dependency types are considered, matching
+// beads.IsReadyBlockingDependencyType; parent-child and tracks edges never
+// block readiness. Status interpretation is left to the shared Go filter:
+// any non-closed blocker counts.
+//
+// Enrichment is fail-open: a failed or unparseable `bd show` / `bd list`
+// degrades to the stock behavior of serving the candidate unchanged, never to
+// dropping it, so a malformed or log-prefixed bd stdout can never disable
+// crash recovery.
+func inProgressBlockedByEnrichmentScript(shellVar string) string {
+ const blockingDepsJQ = `[.[0].dependencies[]? | ` +
+ `select(.dependency_type == "blocks" or .dependency_type == "waits-for" or ` +
+ `.dependency_type == "conditional-blocks") | {id, status}]`
+ const openBlockerCountJQ = `[.[] | select(((.status // "") | ascii_downcase) != "closed")] | length`
+
+ const enrichJQ = `map(. + {blocked_by: $bb})`
+
+ v := `$` + shellVar
+ // The enriched payload lands in a scratch var derived from shellVar so the
+ // candidate itself is never clobbered: if jq fails (non-JSON or
+ // log-prefixed `bd list` stdout) the original is served unchanged.
+ enrichedVar := shellVar + `_enriched`
+ e := `$` + enrichedVar
+ return `bid=$(printf "%s" "` + v + `" | jq -r ".[0].id // empty" 2>/dev/null); ` +
+ `bb="[]"; ` +
+ `[ -n "$bid" ] && bb=$(bd show "$bid" --json 2>/dev/null | ` +
+ `jq -c ` + shellquote.Quote(blockingDepsJQ) + ` 2>/dev/null); ` +
+ `[ -z "$bb" ] && bb="[]"; ` +
+ `nblocked=$(printf "%s" "$bb" | jq -r ` + shellquote.Quote(openBlockerCountJQ) + ` 2>/dev/null); ` +
+ `[ -z "$nblocked" ] && nblocked=0; ` +
+ `if [ "$nblocked" = "0" ]; then ` +
+ enrichedVar + `=$(printf "%s" "` + v + `" | jq -c --argjson bb "$bb" ` +
+ shellquote.Quote(enrichJQ) + ` 2>/dev/null); ` +
+ `[ -n "` + e + `" ] && [ "` + e + `" != "[]" ] && ` + shellVar + `="` + e + `"; ` +
+ `printf "%s" "` + v + `" && exit 0; ` +
+ `fi; `
+}
+
func standardAssignedReadyWorkQueryScript(includeEphemeralReady bool) string {
return `for id in "$GC_SESSION_ID" "$GC_SESSION_NAME" "$GC_ALIAS"; do ` +
`[ -z "$id" ] && continue; ` +
@@ -210,7 +298,9 @@ func legacyControlAssignedInProgressWorkQueryScript(includeEphemeralReady bool)
`for cand in "$id" "$legacy"; do ` +
`[ -z "$cand" ] && continue; ` +
`r=$(bd list --status in_progress --assignee="$cand" --json --limit=1 2>/dev/null); ` +
- `[ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; ` +
+ `if [ -n "$r" ] && [ "$r" != "[]" ]; then ` +
+ inProgressBlockedByEnrichmentScript("r") +
+ `fi; ` +
ephemeralAssignedInProgressProbeScript("cand", includeEphemeralReady) +
`done; ` +
`done; `
@@ -240,7 +330,7 @@ func ephemeralAssignedReadyProbeScript(shellVar string, includeEphemeralReady bo
if includeEphemeralReady {
return ""
}
- filter := legacyEphemeralReadyFilterJQ(`select((.assignee // "") == $id)`, 1)
+ filter := legacyEphemeralReadyFilterJQ(`select((.assignee // "") == $id)`, 1, false)
return `r=$(` + bdQueryEphemeralStatusQuietShell("open") + ` | ` +
`jq --arg id "$` + shellVar + `" ` + shellquote.Quote(filter) + ` 2>/dev/null); ` +
`[ -n "$r" ] && [ "$r" != "[]" ] && printf "%s" "$r" && exit 0; `
diff --git a/internal/config/workquery_hold_label_test.go b/internal/config/workquery_hold_label_test.go
new file mode 100644
index 0000000000..13d0641967
--- /dev/null
+++ b/internal/config/workquery_hold_label_test.go
@@ -0,0 +1,74 @@
+package config
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+)
+
+// This file expresses the ga-x9kptu / ga-5736js acceptance criteria at the
+// shell-generator level: route-scoped, unassigned pool-demand queries (Tier
+// 3, and the reconciler's count-form) must exclude beads carrying a
+// beadmeta.DispatchHoldLabels value, while the assignee-scoped ephemeral
+// probe (Tier 1/2) stays hold-transparent.
+
+func TestBdReadyPoolDemandShellExcludesDispatchHoldLabels(t *testing.T) {
+ got := bdReadyPoolDemandShell("--limit 0", false)
+ for _, label := range beadmeta.DispatchHoldLabels {
+ want := `--exclude-label "` + label + `"`
+ if !strings.Contains(got, want) {
+ t.Errorf("bdReadyPoolDemandShell() = %q, missing %q", got, want)
+ }
+ }
+}
+
+func TestBdReadyPoolDemandMigrationShellExcludesDispatchHoldLabels(t *testing.T) {
+ got := bdReadyPoolDemandMigrationShell("--limit=20", false)
+ for _, label := range beadmeta.DispatchHoldLabels {
+ want := `--exclude-label "` + label + `"`
+ if !strings.Contains(got, want) {
+ t.Errorf("bdReadyPoolDemandMigrationShell() = %q, missing %q", got, want)
+ }
+ }
+}
+
+func TestLegacyEphemeralPoolDemandShellRouteScopedExcludesDispatchHoldLabels(t *testing.T) {
+ got := legacyEphemeralPoolDemandShell(20, false, true)
+ if !strings.Contains(got, ".labels") {
+ t.Errorf("legacyEphemeralPoolDemandShell() = %q, missing a .labels reference", got)
+ }
+ for _, label := range beadmeta.DispatchHoldLabels {
+ if !strings.Contains(got, `"`+label+`"`) {
+ t.Errorf("legacyEphemeralPoolDemandShell() = %q, missing hold label %q", got, label)
+ }
+ }
+}
+
+func TestEphemeralAssignedReadyProbeScriptDoesNotExcludeDispatchHoldLabels(t *testing.T) {
+ got := ephemeralAssignedReadyProbeScript("cand", false)
+ if strings.Contains(got, "--exclude-label") || strings.Contains(got, ".labels") {
+ t.Errorf("ephemeralAssignedReadyProbeScript() = %q, assignee-scoped tier must stay hold-transparent", got)
+ }
+}
+
+func TestEffectiveRoutedPoolQueryCarriesHoldLabelExclusionForLegacyAlias(t *testing.T) {
+ a := &Agent{Name: ControlDispatcherAgentName, Dir: "rig"}
+ got := a.EffectiveRoutedPoolQuery()
+ for _, label := range beadmeta.DispatchHoldLabels {
+ want := `--exclude-label "` + label + `"`
+ if !strings.Contains(got, want) {
+ t.Errorf("EffectiveRoutedPoolQuery() (legacy-alias agent) = %q, missing %q", got, want)
+ }
+ }
+}
+
+func TestPoolDemandCountShellInheritsDispatchHoldLabelExclusion(t *testing.T) {
+ got := poolDemandCountShell("hello-world/worker", false)
+ for _, label := range beadmeta.DispatchHoldLabels {
+ want := `--exclude-label "` + label + `"`
+ if !strings.Contains(got, want) {
+ t.Errorf("poolDemandCountShell() = %q, missing %q (reconciler count-form must inherit the claim-path fix)", got, want)
+ }
+ }
+}
diff --git a/internal/config/workquery_inprogress_blocked_test.go b/internal/config/workquery_inprogress_blocked_test.go
new file mode 100644
index 0000000000..0b51259477
--- /dev/null
+++ b/internal/config/workquery_inprogress_blocked_test.go
@@ -0,0 +1,237 @@
+package config
+
+import (
+ "encoding/json"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/shellquote"
+)
+
+// Regression coverage for the crash-recovery re-serve defect: the in_progress
+// ("crash recovery") work-query tier used to return a bead that could not
+// progress, because `bd list --status in_progress` performs no readiness
+// computation and emits neither blocked_by nor is_blocked. The hook-side
+// defensive filter (filterUnreadyHookCandidates -> isDepBlockedHookCandidate)
+// keys on blocked_by, so an absent array read as "not blocked" and a
+// gate-blocked or dependency-blocked step was re-served on every hook tick.
+//
+// These tests EXECUTE the generated shell against a fake `bd` on PATH, so they
+// pin observable behavior rather than the script's spelling (the byte-for-byte
+// shape is pinned separately by TestWorkQueryGolden).
+//
+// Substituting `bd ready` for `bd list` is NOT a valid fix -- bd ready excludes
+// in_progress by design -- so TestInProgressTierServesUnblockedCandidate below
+// is load-bearing: without it, a "fix" that silences the churn by serving
+// nothing at all would look green.
+
+const inProgressListRow = `[{"id":"wk-1","status":"in_progress","assignee":"sess-1","title":"work"}]`
+
+// fakeBdWithDeps returns a fake bd that reports one in_progress assigned bead
+// from `bd list` and the given dependency rows from `bd show`. `bd ready`
+// returns empty so assertions isolate the in_progress tier.
+func fakeBdWithDeps(depsJSON string) string {
+ return `#!/bin/sh
+case "$1" in
+ list) printf '%s' '` + inProgressListRow + `' ;;
+ show) printf '%s' '[{"id":"wk-1","status":"in_progress","dependencies":` + depsJSON + `}]' ;;
+ *) printf '[]' ;;
+esac
+`
+}
+
+// runInProgressTier executes the in_progress tier of the default work query
+// against a fake bd and returns the decoded rows.
+func runInProgressTier(t *testing.T, bdScript string) []map[string]any {
+ t.Helper()
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ // `printf "[]"` is the terminal fallback the real query uses when no tier
+ // produces a candidate.
+ script := standardAssignedInProgressWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"}, bdScript)
+
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
+ t.Fatalf("tier output is not a JSON array: %v (output %q)", err, out)
+ }
+ return rows
+}
+
+// TestInProgressTierSkipsGateBlockedCandidate is the primary regression: a
+// human gate filed after the step was claimed stores a ready-blocking "blocks"
+// edge on the blocked bead. The tier must not serve it.
+func TestInProgressTierSkipsGateBlockedCandidate(t *testing.T) {
+ rows := runInProgressTier(t, fakeBdWithDeps(
+ `[{"id":"gate-1","status":"open","dependency_type":"blocks","await_type":"human"}]`))
+ if len(rows) != 0 {
+ t.Fatalf("gate-blocked in_progress bead was re-served by the crash-recovery tier: %v", rows)
+ }
+}
+
+// TestInProgressTierSkipsDependencyBlockedCandidate pins that the defect is not
+// gate-specific: a plain unclosed "blocks" dependency is the same edge type and
+// must suppress the re-serve identically.
+func TestInProgressTierSkipsDependencyBlockedCandidate(t *testing.T) {
+ rows := runInProgressTier(t, fakeBdWithDeps(
+ `[{"id":"dep-1","status":"open","dependency_type":"blocks"}]`))
+ if len(rows) != 0 {
+ t.Fatalf("dependency-blocked in_progress bead was re-served: %v", rows)
+ }
+}
+
+// TestInProgressTierServesUnblockedCandidate is the anti-regression guard for
+// the fix itself: crash recovery must still work. A fix that simply swapped in
+// `bd ready` (which excludes in_progress) would stop the churn while silently
+// disabling recovery, and would fail here.
+func TestInProgressTierServesUnblockedCandidate(t *testing.T) {
+ rows := runInProgressTier(t, fakeBdWithDeps(`[]`))
+ if len(rows) != 1 {
+ t.Fatalf("unblocked in_progress bead was NOT served; crash recovery is broken: %v", rows)
+ }
+ if rows[0]["id"] != "wk-1" {
+ t.Fatalf("served the wrong bead: %v", rows)
+ }
+ if _, ok := rows[0]["blocked_by"]; !ok {
+ t.Errorf("served row is missing the blocked_by array the hook-side filter reads: %v", rows)
+ }
+}
+
+// TestInProgressTierServesCandidateWithClosedBlocker pins that a resolved gate
+// releases the step. Without this, answering a gate would strand the work
+// instead of resuming it.
+func TestInProgressTierServesCandidateWithClosedBlocker(t *testing.T) {
+ rows := runInProgressTier(t, fakeBdWithDeps(
+ `[{"id":"gate-1","status":"closed","dependency_type":"blocks","await_type":"human"}]`))
+ if len(rows) != 1 {
+ t.Fatalf("step with a CLOSED blocker was not resumed: %v", rows)
+ }
+}
+
+// TestInProgressTierIgnoresNonBlockingDependencyTypes pins the type filter
+// against beads.IsReadyBlockingDependencyType. parent-child and tracks edges
+// never block readiness -- treating them as blockers would strand every
+// molecule step, since each carries a tracks/parent-child edge to its root.
+func TestInProgressTierIgnoresNonBlockingDependencyTypes(t *testing.T) {
+ for _, depType := range []string{"parent-child", "tracks", "related", "discovered-from"} {
+ t.Run(depType, func(t *testing.T) {
+ rows := runInProgressTier(t, fakeBdWithDeps(
+ `[{"id":"root-1","status":"open","dependency_type":"`+depType+`"}]`))
+ if len(rows) != 1 {
+ t.Fatalf("non-blocking %q edge wrongly suppressed the re-serve: %v", depType, rows)
+ }
+ })
+ }
+}
+
+// TestInProgressTierServesUnparseableCandidateUnchanged pins the fail-open
+// policy of the blocked_by enrichment: when `bd list` stdout is not a parseable
+// JSON array (a log-prefixed blob, a diagnostic line, an envelope shape), jq
+// cannot enrich it, and the tier must still serve the candidate byte-for-byte
+// as the stock script did. An enrichment that assigned the failed jq result
+// back over the candidate would drop the row instead and silently disable
+// crash recovery -- the exact failure this test exists to catch.
+func TestInProgressTierServesUnparseableCandidateUnchanged(t *testing.T) {
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ const blob = "warning: store not initialized\nargs=list --status in_progress"
+ bdScript := "#!/bin/sh\nprintf '%s' " + shellquote.Quote(blob) + "\n"
+
+ script := standardAssignedInProgressWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"}, bdScript)
+
+ if out != blob {
+ t.Fatalf("unparseable bd list stdout was not served unchanged: got %q, want %q", out, blob)
+ }
+}
+
+// TestLegacyControlInProgressTierServesUnparseableCandidateUnchanged is the
+// matching fail-open guard for the legacy-control shape.
+func TestLegacyControlInProgressTierServesUnparseableCandidateUnchanged(t *testing.T) {
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ const blob = "warning: store not initialized\nargs=list --status in_progress"
+ bdScript := "#!/bin/sh\nprintf '%s' " + shellquote.Quote(blob) + "\n"
+
+ script := legacyControlAssignedInProgressWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"}, bdScript)
+
+ if out != blob {
+ t.Fatalf("unparseable bd list stdout was not served unchanged: got %q, want %q", out, blob)
+ }
+}
+
+// TestInProgressTierFallsThroughWhenBlocked pins that a blocked candidate does
+// not swallow the tick: the ready-gated tier still runs, so a session holding
+// one blocked step can still be served its other ready assigned work. The stock
+// script short-circuited with `&& exit 0` and never reached the ready tier.
+func TestInProgressTierFallsThroughWhenBlocked(t *testing.T) {
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ // Same fake bd, except `bd ready` yields a different, genuinely ready bead.
+ bdScript := `#!/bin/sh
+case "$1" in
+ list) printf '%s' '` + inProgressListRow + `' ;;
+ show) printf '%s' '[{"id":"wk-1","status":"in_progress","dependencies":[{"id":"gate-1","status":"open","dependency_type":"blocks"}]}]' ;;
+ ready) printf '%s' '[{"id":"wk-2","status":"open","assignee":"sess-1"}]' ;;
+ *) printf '[]' ;;
+esac
+`
+ script := standardAssignedWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"}, bdScript)
+
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
+ t.Fatalf("tier output is not a JSON array: %v (output %q)", err, out)
+ }
+ if len(rows) != 1 || rows[0]["id"] != "wk-2" {
+ t.Fatalf("blocked in_progress candidate did not fall through to the ready tier; got %q", out)
+ }
+}
+
+// TestLegacyControlInProgressTierSkipsBlockedCandidate pins that the
+// control-dispatcher variant of the same tier
+// (legacyControlAssignedInProgressWorkQueryScript) carries the identical
+// dep-blind `bd list --status in_progress` query and therefore the identical
+// defect. Fixing only the standard tier would leave rigs on the legacy control
+// shape churning exactly as before.
+func TestLegacyControlInProgressTierSkipsBlockedCandidate(t *testing.T) {
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ script := legacyControlAssignedInProgressWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"},
+ fakeBdWithDeps(`[{"id":"gate-1","status":"open","dependency_type":"blocks","await_type":"human"}]`))
+
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
+ t.Fatalf("tier output is not a JSON array: %v (output %q)", err, out)
+ }
+ if len(rows) != 0 {
+ t.Fatalf("legacy-control tier re-served a gate-blocked in_progress bead: %v", rows)
+ }
+}
+
+// TestLegacyControlInProgressTierServesUnblockedCandidate is the matching
+// anti-regression guard: crash recovery must survive on the legacy shape too.
+func TestLegacyControlInProgressTierServesUnblockedCandidate(t *testing.T) {
+ if _, err := exec.LookPath("jq"); err != nil {
+ t.Skip("jq not available; the work-query shell requires it")
+ }
+ script := legacyControlAssignedInProgressWorkQueryScript(false) + `printf "[]"`
+ out := runShellWithFakeBd(t, script, map[string]string{"GC_SESSION_ID": "sess-1"},
+ fakeBdWithDeps(`[]`))
+
+ var rows []map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
+ t.Fatalf("tier output is not a JSON array: %v (output %q)", err, out)
+ }
+ if len(rows) != 1 || rows[0]["id"] != "wk-1" {
+ t.Fatalf("legacy-control tier did not serve an unblocked in_progress bead: %v", rows)
+ }
+}
diff --git a/internal/convergence/artifact.go b/internal/convergence/artifact.go
index cf0154e3a7..9972f5123d 100644
--- a/internal/convergence/artifact.go
+++ b/internal/convergence/artifact.go
@@ -32,6 +32,12 @@ func ValidateArtifactDir(dir string) error {
}
// Canonicalize with EvalSymlinks so comparisons are consistent
// when the artifact root itself contains symlinked components.
+ // canonical-path-exception: existence/resolvability only, not comparison
+ // preparation. absDir is already absolute (filepath.Abs above), so this
+ // call cannot diverge into a relative/absolute mismatch; its error path
+ // is the deliberate "artifact directory must exist" check for this
+ // function, which pathutil.NormalizePathForCompare's never-errors
+ // contract would silently swallow.
absDir, err = filepath.EvalSymlinks(absDir)
if err != nil {
return fmt.Errorf("resolving artifact directory: %w", err)
@@ -46,6 +52,13 @@ func ValidateArtifactDir(dir string) error {
// Check for symlinks using EvalSymlinks for full resolution
// (handles multi-hop chains), consistent with ResolveConditionPath.
+ // canonical-path-exception: existence/resolvability only, not
+ // comparison preparation. path comes from WalkDir(absDir, ...) and
+ // is already absolute, so this cannot diverge into a
+ // relative/absolute mismatch; a broken or unresolvable symlink
+ // target must fail directory validation here, which
+ // pathutil.NormalizePathForCompare's never-errors contract would
+ // silently paper over.
if typ&os.ModeSymlink != 0 {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
diff --git a/internal/convergence/artifact_test.go b/internal/convergence/artifact_test.go
index 94b2127677..4041784b63 100644
--- a/internal/convergence/artifact_test.go
+++ b/internal/convergence/artifact_test.go
@@ -145,3 +145,17 @@ func TestValidateArtifactDir_FIFO(t *testing.T) {
t.Errorf("error should mention unsafe file type, got: %v", err)
}
}
+
+// Regression-pins ValidateArtifactDir's existence-check behavior (refs
+// ga-iawy13.4): a missing artifact directory must still produce an error.
+// This root EvalSymlinks site is deliberate existence checking, not
+// comparison preparation, and must keep failing the same way after the
+// canonical-path-at-ingest migration.
+func TestValidateArtifactDir_MissingDir(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "does-not-exist")
+
+ err := ValidateArtifactDir(dir)
+ if err == nil {
+ t.Fatal("expected error for missing artifact directory, got nil")
+ }
+}
diff --git a/internal/convergence/condition.go b/internal/convergence/condition.go
index d3675fc8a7..c15f6cb3fc 100644
--- a/internal/convergence/condition.go
+++ b/internal/convergence/condition.go
@@ -199,18 +199,25 @@ func ResolveConditionPath(envelope, base, conditionPath string) (string, error)
base = envelope
}
- // Canonicalize envelope and base first so that symlinked workspace
- // roots (e.g., /tmp → /private/tmp on macOS) don't cause false
- // rejections and so the post-resolution containment check below
- // compares like with like.
- canonEnvelope, err := filepath.EvalSymlinks(envelope)
- if err != nil {
- canonEnvelope = filepath.Clean(envelope) // best-effort if envelope doesn't exist yet
- }
- canonBase, err := filepath.EvalSymlinks(base)
- if err != nil {
- canonBase = filepath.Clean(base) // best-effort if base doesn't exist yet
- }
+ // Canonicalize envelope and base first via pathutil.NormalizePathForCompare,
+ // which absolutizes before resolving symlinks (falling back to a
+ // best-effort ancestor walk when the path doesn't exist yet). This keeps
+ // symlinked workspace roots (e.g., /tmp → /private/tmp on macOS) from
+ // causing false rejections, keeps a relative envelope/base (e.g. ".")
+ // from staying relative while a resolved target becomes absolute via a
+ // symlink — which broke filepath.Rel in the containment checks below —
+ // and ensures the post-resolution containment check compares like with
+ // like.
+ //
+ // NormalizePathForCompare does more than absolutize-and-resolve: on
+ // darwin it also collapses the /private/tmp and /private/var host
+ // aliases back to /tmp and /var, which is the REVERSE direction from
+ // bare filepath.EvalSymlinks. Any value compared against canonEnvelope
+ // or canonBase must therefore go through pathutil too — a bare
+ // EvalSymlinks result is in a different convention and will mismatch on
+ // darwin even when the paths name the same location.
+ canonEnvelope := pathutil.NormalizePathForCompare(envelope)
+ canonBase := pathutil.NormalizePathForCompare(base)
var absPath string
if filepath.IsAbs(conditionPath) {
@@ -231,6 +238,11 @@ func ResolveConditionPath(envelope, base, conditionPath string) (string, error)
// Resolve symlinks to the real path. Scripts may be symlinked from
// a shared tooling directory (e.g., ~/tooling/scripts/).
+ // canonical-path-exception: existence/resolvability only, not comparison
+ // preparation. This call's error path is the behavior — a dangling or
+ // unresolvable conditionPath must fail gate resolution here, so it
+ // cannot be replaced with pathutil.NormalizePathForCompare, which never
+ // errors.
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
return "", fmt.Errorf("resolving gate condition path: %w", err)
@@ -241,8 +253,16 @@ func ResolveConditionPath(envelope, base, conditionPath string) (string, error)
// Re-validate the symlink-resolved path against the same envelope-OR-base
// rule to close the symlink-escape gap (gastownhall/gascity#2354 review).
// Absolute paths still skip — same rationale as the pre-resolution check.
+ //
+ // Use pathutil.PathWithin rather than the lexical containedIn: resolved
+ // comes from bare filepath.EvalSymlinks, so on darwin it carries the
+ // /private prefix that canonEnvelope/canonBase have had collapsed away.
+ // PathWithin normalizes both operands, so the alias collapse applies
+ // symmetrically. (The pre-resolution check above keeps containedIn:
+ // absPath is derived from canonBase, so both sides already share a
+ // convention there.)
if !filepath.IsAbs(conditionPath) {
- if !containedIn(resolved, canonEnvelope) && !containedIn(resolved, canonBase) {
+ if !pathutil.PathWithin(canonEnvelope, resolved) && !pathutil.PathWithin(canonBase, resolved) {
return "", fmt.Errorf("resolving gate condition path: symlink target outside containment: %s", conditionPath)
}
}
diff --git a/internal/convergence/condition_test.go b/internal/convergence/condition_test.go
index 1a830f0c0b..2a3fcbdd39 100644
--- a/internal/convergence/condition_test.go
+++ b/internal/convergence/condition_test.go
@@ -520,6 +520,84 @@ func TestResolveConditionPath(t *testing.T) {
t.Errorf("expected path traversal error, got: %v", err)
}
})
+
+ // Pins the canonical-path-at-ingest bug this migration fixes
+ // (ga-iawy13.4): a relative envelope (e.g. "." from an
+ // as-yet-unresolved city path) combined with a conditionPath that
+ // crosses a symlink component makes the current bare
+ // EvalSymlinks-without-Abs canonicalization produce an ABSOLUTE
+ // resolved target while canonEnvelope/canonBase stay RELATIVE.
+ // filepath.Rel(relative, absolute) errors, and containedIn treats any
+ // Rel error as "not contained" — so a completely legitimate, safely
+ // contained path is falsely rejected as escaping containment. Once
+ // canonEnvelope/canonBase are normalized via
+ // pathutil.NormalizePathForCompare (which absolutizes first), this
+ // must succeed.
+ t.Run("relative envelope combined with a symlinked conditionPath segment must not be falsely rejected", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink semantics differ on Windows")
+ }
+ dir := t.TempDir()
+ realDir := filepath.Join(dir, "real")
+ if err := os.MkdirAll(realDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ script := filepath.Join(realDir, "check.sh")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(realDir, filepath.Join(dir, "alias")); err != nil {
+ t.Skipf("symlinks not supported: %v", err)
+ }
+
+ t.Chdir(dir)
+
+ got, err := ResolveConditionPath(".", "", "alias/check.sh")
+ if err != nil {
+ t.Fatalf("unexpected error: %v — envelope/base must be canonicalized to absolute before containment comparison, not left relative", err)
+ }
+ testutil.AssertSamePath(t, got, script)
+ })
+
+ // Pins the darwin half of the same comparison contract: on macOS the
+ // system temp root lives under /var (or /tmp), which EvalSymlinks
+ // expands to /private/var (or /private/tmp) while
+ // pathutil.NormalizePathForCompare collapses it back the other way.
+ // canonEnvelope/canonBase therefore carry the collapsed spelling while
+ // the post-resolution `resolved` (bare EvalSymlinks) carries the
+ // /private spelling — a lexical containment check compares the two
+ // conventions and falsely rejects a plainly contained script. The
+ // containment check must normalize both sides.
+ //
+ // This needs the real os.TempDir() root, not an arbitrary directory:
+ // the /private alias only exists on the platform temp trees. No symlink
+ // is created by the test — the platform's own /var symlink is the
+ // trigger.
+ t.Run("darwin private temp alias must not falsely reject a contained relative condition path", func(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("darwin-only: the /private/{tmp,var} alias collapse is a no-op on other platforms")
+ }
+ root, err := os.MkdirTemp(os.TempDir(), "gc-cond-alias-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(root) })
+
+ scripts := filepath.Join(root, "scripts")
+ if err := os.MkdirAll(scripts, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ script := filepath.Join(scripts, "check.sh")
+ if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := ResolveConditionPath(root, "", "scripts/check.sh")
+ if err != nil {
+ t.Fatalf("unexpected error: %v — post-resolution containment must normalize both operands, not compare a /private-prefixed resolved path against an alias-collapsed envelope", err)
+ }
+ testutil.AssertSamePath(t, got, script)
+ })
}
func TestRunConditionPass(t *testing.T) {
diff --git a/internal/convergence/evaluate.go b/internal/convergence/evaluate.go
index ab8902605c..af6c034b3c 100644
--- a/internal/convergence/evaluate.go
+++ b/internal/convergence/evaluate.go
@@ -41,12 +41,20 @@ func ResolveEvaluateStep(cityPath string, formula Formula) (EvaluateStep, error)
promptPath = formula.EvaluatePrompt
}
- // Canonicalize cityPath first so that symlinked workspace roots
- // (e.g., /tmp -> /private/tmp on macOS) don't cause false rejections.
- canonCity, err := filepath.EvalSymlinks(cityPath)
- if err != nil {
- canonCity = filepath.Clean(cityPath) // best-effort if city doesn't exist yet
- }
+ // Canonicalize cityPath first via pathutil.NormalizePathForCompare, which
+ // absolutizes before resolving symlinks (falling back to a best-effort
+ // ancestor walk when the path doesn't exist yet). This keeps symlinked
+ // workspace roots (e.g., /tmp -> /private/tmp on macOS) from causing
+ // false rejections, and keeps a relative cityPath (e.g. ".") from
+ // producing a relative PromptPath below.
+ //
+ // NormalizePathForCompare does more than absolutize-and-resolve: on
+ // darwin it also collapses the /private/tmp and /private/var host
+ // aliases back to /tmp and /var, which is the REVERSE direction from
+ // bare filepath.EvalSymlinks. resolved is built on canonCity and so
+ // inherits that convention; any value compared against it must pass
+ // through pathutil too.
+ canonCity := pathutil.NormalizePathForCompare(cityPath)
resolved := filepath.Clean(filepath.Join(canonCity, promptPath))
@@ -57,8 +65,24 @@ func ResolveEvaluateStep(cityPath string, formula Formula) (EvaluateStep, error)
}
// Reject symlinks in the resolved path (matching ResolveConditionPath).
+ // canonical-path-exception: existence/resolvability only, not comparison
+ // preparation. This deliberately checks whether the resolved path IS a
+ // symlink — a blanket "reject any symlink component" policy that is
+ // stricter than, and different in kind from, plain containment — and
+ // silently tolerates an unresolvable path (err != nil) rather than
+ // failing, so pathutil.NormalizePathForCompare's fallback-and-never-error
+ // contract would change this function's behavior, not just its
+ // canonicalization.
+ //
+ // Only realResolved is normalized before the comparison. It is already
+ // fully symlink-resolved, so NormalizePathForCompare on it amounts to
+ // the darwin alias collapse alone — which puts it in the same convention
+ // as resolved (built on the collapsed canonCity). Do NOT switch this to
+ // pathutil.SamePath: that would normalize resolved too, re-resolving it
+ // through its own symlink, so a genuinely symlinked prompt would compare
+ // equal and this rejection would stop firing.
realResolved, err := filepath.EvalSymlinks(resolved)
- if err == nil && realResolved != resolved {
+ if err == nil && pathutil.NormalizePathForCompare(realResolved) != resolved {
return EvaluateStep{}, fmt.Errorf("evaluate prompt path contains symlinks: %s resolves to %s", resolved, realResolved)
}
diff --git a/internal/convergence/evaluate_test.go b/internal/convergence/evaluate_test.go
index c3aa6e5973..ca94113f5a 100644
--- a/internal/convergence/evaluate_test.go
+++ b/internal/convergence/evaluate_test.go
@@ -1,9 +1,13 @@
package convergence
import (
+ "os"
"path/filepath"
+ "runtime"
"strings"
"testing"
+
+ "github.com/gastownhall/gascity/internal/testutil"
)
func TestResolveEvaluateStep_DefaultPath(t *testing.T) {
@@ -16,10 +20,13 @@ func TestResolveEvaluateStep_DefaultPath(t *testing.T) {
if step.Name != EvaluateStepName {
t.Errorf("Name = %q, want %q", step.Name, EvaluateStepName)
}
- want := filepath.Join("/home/user/city", DefaultEvaluatePromptPath)
- if step.PromptPath != want {
- t.Errorf("PromptPath = %q, want %q", step.PromptPath, want)
- }
+ // Compared via testutil.AssertSamePath, not ==, because upstream migrated
+ // ResolveEvaluateStep to pathutil.NormalizePathForCompare (ga-iawy13.4):
+ // canonCity now resolves symlinks, so on a host where /home is a symlink
+ // (macOS firmlink -> /System/Volumes/Data/home) a raw string compare fails
+ // on a correct result. Upstream's newer tests in this file already use the
+ // tolerant helper; these two predate it.
+ testutil.AssertCanonicalPathEquals(t, step.PromptPath, filepath.Join("/home/user/city", DefaultEvaluatePromptPath))
}
func TestResolveEvaluateStep_CustomPath(t *testing.T) {
@@ -35,10 +42,7 @@ func TestResolveEvaluateStep_CustomPath(t *testing.T) {
if step.Name != EvaluateStepName {
t.Errorf("Name = %q, want %q", step.Name, EvaluateStepName)
}
- want := filepath.Join("/home/user/city", "custom/my-evaluate.md")
- if step.PromptPath != want {
- t.Errorf("PromptPath = %q, want %q", step.PromptPath, want)
- }
+ testutil.AssertCanonicalPathEquals(t, step.PromptPath, filepath.Join("/home/user/city", "custom/my-evaluate.md"))
}
func TestResolveEvaluateStep_PathTraversal(t *testing.T) {
@@ -112,3 +116,100 @@ func TestValidateEvaluatePrompt_EmptyContent(t *testing.T) {
t.Errorf("error should mention missing 'convergence.agent_verdict', got: %v", err)
}
}
+
+// Pins the canonical-path-at-ingest bug this migration fixes (ga-iawy13.4):
+// a relative cityPath (e.g. "." from an as-yet-unresolved city path) makes
+// the current bare EvalSymlinks-without-Abs canonicalization leave
+// canonCity relative, so the function silently succeeds but returns a
+// relative PromptPath instead of an absolute one. Once canonCity is
+// normalized via pathutil.NormalizePathForCompare (which absolutizes
+// first), PromptPath must be absolute.
+func TestResolveEvaluateStep_RelativeCityPathReturnsAbsolutePromptPath(t *testing.T) {
+ dir := t.TempDir()
+ t.Chdir(dir)
+
+ f := Formula{Name: "test"}
+ step, err := ResolveEvaluateStep(".", f)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !filepath.IsAbs(step.PromptPath) {
+ t.Fatalf("PromptPath = %q, want an absolute path — cityPath must be canonicalized to absolute before joining, not left relative", step.PromptPath)
+ }
+ want := filepath.Join(dir, DefaultEvaluatePromptPath)
+ if step.PromptPath != want {
+ t.Errorf("PromptPath = %q, want %q", step.PromptPath, want)
+ }
+}
+
+// Pins the symlink-presence rejection itself, which the comparison above sits
+// on top of. Normalizing realResolved must not weaken it: normalizing BOTH
+// operands (e.g. via pathutil.SamePath) would re-resolve the prompt path
+// through its own symlink, both sides would compare equal, and this rejection
+// would silently stop firing. Portable — this runs on every platform, unlike
+// the darwin-guarded alias tests.
+func TestResolveEvaluateStep_SymlinkedPromptStillRejected(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink semantics differ on Windows")
+ }
+ city := t.TempDir()
+ outside := t.TempDir()
+
+ target := filepath.Join(outside, "real-evaluate.md")
+ if err := os.WriteFile(target, []byte("bd meta set convergence.agent_verdict\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(city, DefaultEvaluatePromptPath)
+ if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, link); err != nil {
+ t.Skipf("symlinks not supported: %v", err)
+ }
+
+ _, err := ResolveEvaluateStep(city, Formula{Name: "test"})
+ if err == nil {
+ t.Fatal("expected a symlinked evaluate prompt to be rejected, got nil")
+ }
+ if !strings.Contains(err.Error(), "contains symlinks") {
+ t.Errorf("expected a symlink rejection, got: %v", err)
+ }
+}
+
+// Pins the darwin half of the same comparison contract. canonCity comes from
+// pathutil.NormalizePathForCompare, which on macOS collapses the platform
+// temp root's /private/var (or /private/tmp) spelling back to /var (or /tmp);
+// the symlink-presence check's realResolved comes from bare EvalSymlinks and
+// carries the /private spelling. Comparing the two raw conventions rejects a
+// prompt file that is not a symlink at all, so realResolved must be
+// normalized before the comparison.
+//
+// This needs the real os.TempDir() root (the /private alias only exists on the
+// platform temp trees) AND the prompt file actually present on disk — the
+// check is guarded by `err == nil`, so a missing file makes EvalSymlinks fail
+// and the comparison is skipped entirely.
+func TestResolveEvaluateStep_DarwinPrivateTempAliasWithExistingPrompt(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("darwin-only: the /private/{tmp,var} alias collapse is a no-op on other platforms")
+ }
+ city, err := os.MkdirTemp(os.TempDir(), "gc-eval-alias-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(city) })
+
+ prompt := filepath.Join(city, DefaultEvaluatePromptPath)
+ if err := os.MkdirAll(filepath.Dir(prompt), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(prompt, []byte("bd meta set convergence.agent_verdict\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ step, err := ResolveEvaluateStep(city, Formula{Name: "test"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v — the symlink-presence check must normalize realResolved before comparing it against a path built on the alias-collapsed canonCity", err)
+ }
+ testutil.AssertCanonicalPathEquals(t, step.PromptPath, prompt)
+}
diff --git a/internal/dispatch/retry.go b/internal/dispatch/retry.go
index e01d43d9e5..71c891874a 100644
--- a/internal/dispatch/retry.go
+++ b/internal/dispatch/retry.go
@@ -427,11 +427,27 @@ func requiredArtifactPathInWorktree(worktree, path string) (bool, error) {
return pathutil.PathWithin(absWorktree, absPath), nil
}
+// requiredArtifactTargetInWorktree reports whether path's symlink-resolved
+// target is contained within worktree's symlink-resolved root, tolerating a
+// missing path (treated as contained; the caller's earlier os.Stat is what
+// classifies missing artifacts as failures).
func requiredArtifactTargetInWorktree(worktree, path string) (bool, error) {
+ // canonical-path-exception: existence/resolvability only, not comparison
+ // preparation. worktree is always an absolute git-worktree path stamped
+ // by the controller (never a bare "." or other unresolved relative
+ // value); a worktree that no longer resolves must fail this check,
+ // which pathutil.NormalizePathForCompare's never-errors contract would
+ // silently paper over.
resolvedWorktree, err := filepath.EvalSymlinks(filepath.Clean(worktree))
if err != nil {
return false, fmt.Errorf("resolving required artifact worktree symlinks %q: %w", worktree, err)
}
+ // canonical-path-exception: existence/resolvability only, not comparison
+ // preparation. A missing artifact target is deliberately treated as
+ // contained (true) here — validateRequiredArtifacts' earlier os.Stat
+ // call is what classifies missing/unreadable artifacts as failures;
+ // this function only needs to gate symlink escapes for targets that
+ // exist.
resolvedPath, err := filepath.EvalSymlinks(filepath.Clean(path))
if err != nil {
if os.IsNotExist(err) {
diff --git a/internal/dispatch/retry_test.go b/internal/dispatch/retry_test.go
index 7a0c42d88a..3ea3e5995d 100644
--- a/internal/dispatch/retry_test.go
+++ b/internal/dispatch/retry_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
+ "runtime"
"testing"
"time"
@@ -673,6 +674,83 @@ func TestRequiredArtifactTemplatesTreatsSingularAsOnePath(t *testing.T) {
}
}
+// TestRequiredArtifactTargetInWorktree regression-pins the
+// existence/resolvability checks in requiredArtifactTargetInWorktree's two
+// bare EvalSymlinks calls (refs ga-iawy13.4): a missing target is treated
+// as contained (the caller's earlier os.Stat already classifies
+// missing/unreadable paths, so this function only needs to gate symlink
+// escapes for targets that exist), a symlinked worktree root resolves
+// correctly for a contained target, and a target that escapes via symlink
+// is rejected. These sites are deliberate existence/resolvability
+// checking, not comparison preparation, and must keep behaving identically
+// after the canonical-path-at-ingest migration.
+func TestRequiredArtifactTargetInWorktree(t *testing.T) {
+ t.Parallel()
+
+ t.Run("missing target treated as contained", func(t *testing.T) {
+ t.Parallel()
+ worktree := t.TempDir()
+ missing := filepath.Join(worktree, "does-not-exist.md")
+
+ got, err := requiredArtifactTargetInWorktree(worktree, missing)
+ if err != nil {
+ t.Fatalf("requiredArtifactTargetInWorktree: %v", err)
+ }
+ if !got {
+ t.Fatal("expected missing target to be treated as contained (true)")
+ }
+ })
+
+ t.Run("symlinked worktree root with contained target resolves", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink semantics differ on Windows")
+ }
+ t.Parallel()
+ realDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(realDir, "review.md"), []byte("ok"), 0o644); err != nil {
+ t.Fatalf("write artifact: %v", err)
+ }
+ aliasParent := t.TempDir()
+ alias := filepath.Join(aliasParent, "worktree-alias")
+ if err := os.Symlink(realDir, alias); err != nil {
+ t.Skipf("symlinks not supported: %v", err)
+ }
+
+ got, err := requiredArtifactTargetInWorktree(alias, filepath.Join(alias, "review.md"))
+ if err != nil {
+ t.Fatalf("requiredArtifactTargetInWorktree: %v", err)
+ }
+ if !got {
+ t.Fatal("expected symlinked worktree root with contained target to resolve as contained")
+ }
+ })
+
+ t.Run("target escaping via symlink is rejected", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink semantics differ on Windows")
+ }
+ t.Parallel()
+ worktree := t.TempDir()
+ outside := t.TempDir()
+ outsideFile := filepath.Join(outside, "secret.md")
+ if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
+ t.Fatalf("write outside file: %v", err)
+ }
+ link := filepath.Join(worktree, "review.md")
+ if err := os.Symlink(outsideFile, link); err != nil {
+ t.Skipf("symlinks not supported: %v", err)
+ }
+
+ got, err := requiredArtifactTargetInWorktree(worktree, link)
+ if err != nil {
+ t.Fatalf("requiredArtifactTargetInWorktree: %v", err)
+ }
+ if got {
+ t.Fatal("expected target escaping worktree via symlink to be rejected (false)")
+ }
+ })
+}
+
type fakeFileInfo struct {
size int64
isDir bool
diff --git a/internal/doctor/checks_bd_backup_freshness.go b/internal/doctor/checks_bd_backup_freshness.go
index 31be5f9d91..d7853bbfaa 100644
--- a/internal/doctor/checks_bd_backup_freshness.go
+++ b/internal/doctor/checks_bd_backup_freshness.go
@@ -143,6 +143,45 @@ func (c *BdBackupFreshnessCheck) freshnessScanTargets() []bdBackupFreshnessTarge
return targets
}
+// BulkDeleteSafe reports whether it is safe to perform a bulk bead deletion
+// given the current backup freshness across all managed scopes. It returns
+// safe=false and a human-readable reason as soon as one managed scope's ACTIVE
+// backup pipeline is not demonstrably current.
+//
+// Which pipeline is "active" per scope, and therefore which state file decides
+// freshness, is scanBackupFreshness's judgement — this gate deliberately does
+// not re-derive it, so the gate and BdBackupFreshnessCheck can never disagree
+// about whether a scope is protected. Concretely that means a scope with a
+// registered Dolt destination is judged on its Dolt sync state (including the
+// registered-but-never-synced case, which is unsafe), and only a scope that
+// never migrated is judged on the legacy embedded-store state.
+//
+// The gate is fail-closed on doubt: an unreadable, unparseable, or
+// timestamp-less state file blocks the deletion rather than being ignored,
+// because it leaves the recovery point unknown. The one deliberate exception is
+// a scope with NO backup state at all, which is treated as safe — "no backup
+// configured" is DoltBackupCheck's concern, and failing closed there would
+// block bulk deletion on every unbacked city.
+//
+// maxAge is used as given and is not clamped, so a non-positive value reads
+// every scope as stale and blocks every deletion.
+func BulkDeleteSafe(cityPath string, cfg *config.City, maxAge time.Duration, now time.Time) (bool, string) {
+ check := NewBdBackupFreshnessCheckForConfig(cityPath, cfg, nil)
+ if cfg == nil {
+ // No config in hand: discover scopes from disk, the same fallback the
+ // check uses when city.toml fails to load. Silently narrowing to the
+ // city root here would leave every rig unscanned and fail this gate
+ // OPEN — the one direction a delete gate must never fail.
+ check = NewBdBackupFreshnessCheckForScopeRoots(cityPath, managedDoltScopeRoots(cityPath), maxAge, nil)
+ }
+ for _, target := range check.freshnessScanTargets() {
+ if finding, ok := scanBackupFreshness(target.Label, target.BeadsDir, now, maxAge); ok {
+ return false, finding
+ }
+ }
+ return true, ""
+}
+
// scanBackupFreshness reports whether a scope's ACTIVE backup pipeline has
// stopped syncing.
//
diff --git a/internal/doctor/checks_bd_backup_freshness_test.go b/internal/doctor/checks_bd_backup_freshness_test.go
index 2239923e03..fa65c1e394 100644
--- a/internal/doctor/checks_bd_backup_freshness_test.go
+++ b/internal/doctor/checks_bd_backup_freshness_test.go
@@ -6,8 +6,103 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/gastownhall/gascity/internal/config"
)
+func TestBulkDeleteSafe(t *testing.T) {
+ now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
+ maxAge := 24 * time.Hour
+
+ t.Run("all scopes fresh → safe", func(t *testing.T) {
+ scope1 := t.TempDir()
+ scope2 := t.TempDir()
+ writeBackupStateForFreshness(t, scope1, now.Add(-1*time.Hour).Format(time.RFC3339))
+ writeBackupStateForFreshness(t, scope2, now.Add(-2*time.Hour).Format(time.RFC3339))
+ cfg := &config.City{Rigs: []config.Rig{
+ {Path: scope1},
+ {Path: scope2},
+ }}
+ safe, reason := BulkDeleteSafe(scope1, cfg, maxAge, now)
+ if !safe {
+ t.Fatalf("all fresh: want safe=true, got safe=false, reason=%q", reason)
+ }
+ if reason != "" {
+ t.Fatalf("all fresh: want empty reason, got %q", reason)
+ }
+ })
+
+ t.Run("one stale scope → unsafe, reason contains scope label", func(t *testing.T) {
+ fresh := t.TempDir()
+ stale := t.TempDir()
+ writeBackupStateForFreshness(t, fresh, now.Add(-1*time.Hour).Format(time.RFC3339))
+ writeBackupStateForFreshness(t, stale, now.Add(-48*time.Hour).Format(time.RFC3339))
+ cfg := &config.City{Rigs: []config.Rig{
+ {Path: fresh},
+ {Path: stale},
+ }}
+ safe, reason := BulkDeleteSafe(fresh, cfg, maxAge, now)
+ if safe {
+ t.Fatalf("stale scope: want safe=false, got safe=true")
+ }
+ if !strings.Contains(reason, stale) {
+ t.Fatalf("stale scope: reason should name the stale scope %q, got %q", stale, reason)
+ }
+ })
+
+ t.Run("no backup_state.json in any scope → safe (unconfigured is not this check's job)", func(t *testing.T) {
+ scope1 := t.TempDir()
+ scope2 := t.TempDir()
+ cfg := &config.City{Rigs: []config.Rig{
+ {Path: scope1},
+ {Path: scope2},
+ }}
+ safe, reason := BulkDeleteSafe(scope1, cfg, maxAge, now)
+ if !safe {
+ t.Fatalf("no backup config: want safe=true, got safe=false, reason=%q", reason)
+ }
+ if reason != "" {
+ t.Fatalf("no backup config: want empty reason, got %q", reason)
+ }
+ })
+
+ t.Run("migrated scope with a never-synced dolt backup → unsafe", func(t *testing.T) {
+ scope := t.TempDir()
+ writeDoltBackupRegistration(t, scope) // no dolt-backup-state.json
+ cfg := &config.City{Rigs: []config.Rig{{Path: scope}}}
+ safe, reason := BulkDeleteSafe(scope, cfg, maxAge, now)
+ if safe {
+ t.Fatalf("never-synced dolt backup: want safe=false, got safe=true")
+ }
+ if !strings.Contains(reason, "never synced") {
+ t.Fatalf("reason should say the backup never synced, got %q", reason)
+ }
+ })
+
+ // With no config in hand the gate must discover scopes from disk. Narrowing
+ // to the city root would leave the rig unscanned and return safe=true —
+ // failing this gate OPEN on a destructive operation.
+ t.Run("nil config still scans rigs discovered on disk", func(t *testing.T) {
+ city := t.TempDir()
+ rig := filepath.Join(city, "rigs", "alpha")
+ if err := os.MkdirAll(filepath.Join(rig, ".beads"), 0o755); err != nil {
+ t.Fatalf("mkdir rig .beads: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(rig, ".beads", "metadata.json"), []byte(`{}`), 0o644); err != nil {
+ t.Fatalf("write metadata.json: %v", err)
+ }
+ writeBackupStateForFreshness(t, rig, now.Add(-48*time.Hour).Format(time.RFC3339))
+
+ safe, reason := BulkDeleteSafe(city, nil, maxAge, now)
+ if safe {
+ t.Fatalf("nil config with a stale rig: want safe=false, got safe=true")
+ }
+ if !strings.Contains(reason, "ago") {
+ t.Fatalf("reason should describe the stale age, got %q", reason)
+ }
+ })
+}
+
func writeBackupStateForFreshness(t *testing.T, scopeRoot, timestamp string) {
t.Helper()
dir := filepath.Join(scopeRoot, ".beads", "backup")
diff --git a/internal/doctor/checks_custom_types_test.go b/internal/doctor/checks_custom_types_test.go
index 282b265c24..ff1ff2d769 100644
--- a/internal/doctor/checks_custom_types_test.go
+++ b/internal/doctor/checks_custom_types_test.go
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"testing"
+ "time"
"github.com/gastownhall/gascity/internal/beads/contract"
"github.com/gastownhall/gascity/internal/fsys"
@@ -34,11 +35,21 @@ func TestCustomTypesCheck_MissingTypes(t *testing.T) {
for _, key := range []string{
"BEADS_DIR", "BEADS_ACTOR", "GC_BEADS_SCOPE_ROOT",
"GC_BEADS", "BEADS_DOLT_SERVER_PORT", "GC_DOLT_HOST", "GC_DOLT_PORT",
- "BEADS_DOLT_SERVER_HOST",
+ "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SHARED_SERVER",
+ "BEADS_DOLT_SERVER_MODE", "BEADS_SHARED_SERVER_DIR",
} {
t.Setenv(key, "")
}
+ // Scrubbing env vars alone is not enough: bd's config precedence falls
+ // through to $HOME/.beads/config.yaml as a last resort, so a machine
+ // HOME with dolt.shared-server: true still routes bd to the shared
+ // server — which answers with every required type present and turns
+ // this check StatusOK, defeating the assertion below. Pin a test-owned
+ // HOME so that fallback file doesn't exist. See ga-zxpfic and
+ // TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext.
+ t.Setenv("HOME", t.TempDir())
+
dir := t.TempDir()
beadsDir := filepath.Join(dir, ".beads")
if err := os.MkdirAll(beadsDir, 0o700); err != nil {
@@ -57,6 +68,22 @@ func TestCustomTypesCheck_MissingTypes(t *testing.T) {
}
}
+// retryRemoveAllForTest retries os.RemoveAll briefly to absorb a lingering
+// embedded-dolt background writer that can hold files open a few dozen ms
+// past the owning bd subprocess's apparent exit — which otherwise races
+// t.TempDir()'s single-shot RemoveAll cleanup with an intermittent
+// "directory not empty" error. Falls through silently on final failure so
+// TempDir's own best-effort cleanup still gets the last word.
+func retryRemoveAllForTest(t *testing.T, dir string) {
+ t.Helper()
+ for i := 0; i < 10; i++ {
+ if err := os.RemoveAll(dir); err == nil {
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
// TestCustomTypesCheck_TableDrift proves detect+heal of the bug this bead
// fixes: config.yaml's types.custom CSV can list a type (e.g. "step") that
// the normalized custom_types TABLE doesn't have a row for. bd's create
@@ -87,12 +114,23 @@ func TestCustomTypesCheck_TableDrift(t *testing.T) {
for _, key := range []string{
"BEADS_DIR", "BEADS_ACTOR", "GC_BEADS_SCOPE_ROOT",
"GC_BEADS", "BEADS_DOLT_SERVER_PORT", "GC_DOLT_HOST", "GC_DOLT_PORT",
- "BEADS_DOLT_SERVER_HOST",
+ "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SHARED_SERVER",
+ "BEADS_DOLT_SERVER_MODE", "BEADS_SHARED_SERVER_DIR",
} {
t.Setenv(key, "")
}
+ // Scrubbing env vars alone is not enough: bd's config precedence falls
+ // through to $HOME/.beads/config.yaml as a last resort, so on a fleet
+ // agent HOME with dolt.shared-server: true set there, bd still routes
+ // to the shared server regardless of the vars above. Pin a test-owned
+ // HOME so that fallback file doesn't exist. See ga-zxpfic and
+ // TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext.
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
dir := t.TempDir()
+ t.Cleanup(func() { retryRemoveAllForTest(t, dir) })
runBD := func(args ...string) string {
t.Helper()
@@ -154,6 +192,81 @@ func TestCustomTypesCheck_TableDrift(t *testing.T) {
}
}
+// TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext is a regression
+// test for ga-zxpfic: env-var scrubbing alone does not stop a machine-level
+// dolt.shared-server config from leaking into the bd subprocesses this
+// package's tests spawn. bd's config precedence falls through, as a last
+// resort, to $HOME/.beads/config.yaml — so on any HOME that has
+// dolt.shared-server: true set there (as fleet agent HOMEs do), scrubbing
+// BEADS_DOLT_SERVER_PORT and friends changes nothing: bd still discovers the
+// shared server via that config file, not an env var. Pinning a test-owned
+// HOME via t.TempDir() removes the fallback file entirely, which is the only
+// complete fix — this test asserts that isolation actually holds, not just
+// that the drift check's Run/Fix behavior happens to look right.
+func TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext(t *testing.T) {
+ if _, err := exec.LookPath("bd"); err != nil {
+ t.Skip("bd binary not on PATH")
+ }
+ if _, err := exec.LookPath("dolt"); err != nil {
+ t.Skip("dolt binary not on PATH")
+ }
+
+ for _, key := range []string{
+ "BEADS_DIR", "BEADS_ACTOR", "GC_BEADS_SCOPE_ROOT",
+ "GC_BEADS", "BEADS_DOLT_SERVER_PORT", "GC_DOLT_HOST", "GC_DOLT_PORT",
+ "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SHARED_SERVER",
+ "BEADS_DOLT_SERVER_MODE", "BEADS_SHARED_SERVER_DIR",
+ } {
+ t.Setenv(key, "")
+ }
+
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ dir := t.TempDir()
+ t.Cleanup(func() { retryRemoveAllForTest(t, dir) })
+
+ runBD := func(args ...string) string {
+ t.Helper()
+ cmd := exec.Command("bd", args...)
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("bd %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+ }
+
+ initOut := runBD("init", "--non-interactive", "-p", "tst2", "--skip-hooks", "--skip-agents")
+ setOut := runBD("config", "set", "types.custom", strings.Join(RequiredCustomTypes, ","))
+
+ homeConfigPath := filepath.Join(home, ".beads", "config.yaml")
+ if _, err := os.Stat(homeConfigPath); !os.IsNotExist(err) {
+ t.Fatalf("expected no config.yaml under test-owned HOME %s, but Stat returned err=%v", homeConfigPath, err)
+ }
+
+ metadataPath := filepath.Join(dir, ".beads", "metadata.json")
+ meta, ok, err := contract.LoadMetadataState(fsys.OSFS{}, metadataPath)
+ if err != nil || !ok {
+ t.Fatalf("LoadMetadataState(%s): ok=%v err=%v", metadataPath, ok, err)
+ }
+ if meta.DoltMode != "embedded" {
+ t.Fatalf("metadata.json dolt_mode = %q, want %q", meta.DoltMode, "embedded")
+ }
+ if meta.DoltDatabase == "" {
+ t.Fatal("metadata.json dolt_database is empty, want it to match the embedded store")
+ }
+
+ for _, out := range []string{initOut, setOut} {
+ if strings.Contains(out, "Dolt server at") {
+ t.Fatalf("bd output leaked a shared-server connection: %s", out)
+ }
+ if strings.Contains(out, "shared-server mode is enabled") {
+ t.Fatalf("bd output leaked shared-server mode: %s", out)
+ }
+ }
+}
+
func TestCustomTypesCheck_RequiredTypesIncludeSpec(t *testing.T) {
found := false
for _, typ := range RequiredCustomTypes {
diff --git a/internal/doctor/checks_pack_credentials.go b/internal/doctor/checks_pack_credentials.go
index 6dbd368155..342e371af0 100644
--- a/internal/doctor/checks_pack_credentials.go
+++ b/internal/doctor/checks_pack_credentials.go
@@ -38,7 +38,7 @@ func (c *PackCredentialsCheck) Run(ctx *CheckContext) *CheckResult {
if err != nil {
r.Status = StatusError
r.Message = fmt.Sprintf("pack credentials could not be loaded: %v", err)
- r.FixHint = "fix the credentials.toml permissions (must be 0600) and pointer cardinality, then re-run gc doctor"
+ r.FixHint = "fix the credentials.toml permissions (must be 0600/0400, or root-owned own-group 0440 for a Kubernetes Secret mount) and pointer cardinality, then re-run gc doctor"
return r
}
diff --git a/internal/doctor/skill_dangling_sink_check.go b/internal/doctor/skill_dangling_sink_check.go
new file mode 100644
index 0000000000..3d56597d56
--- /dev/null
+++ b/internal/doctor/skill_dangling_sink_check.go
@@ -0,0 +1,157 @@
+package doctor
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/gastownhall/gascity/internal/materialize"
+)
+
+// SkillDanglingSinkCheck surfaces dangling symlinks in agent skill
+// sinks — links whose target no longer exists. The motivating case is
+// the .gc/system/packs retirement (#3344): its config-only migration
+// stranded every pre-manifest sink link, and the materializer's
+// ownership gate then treated those orphans as user-owned forever
+// (hq-38je). gc doctor previously reported only skill collisions, so
+// the fleet-wide breakage was invisible to the standard health check.
+//
+// The check walks a static sink list (agent scope-root × vendor sinks,
+// resolved by the caller from config) plus a lazily-evaluated live
+// session-workdir sink list, and Lstat/Readlinks every entry. Dangling
+// links are classified gc-owned (target under a legacy/cache root —
+// safe for --fix to remove; the next materialize pass recreates any
+// still-desired link) or user-owned (reported only).
+type SkillDanglingSinkCheck struct {
+ staticSinks []string
+ gcRoots []string
+ liveSinksFn func() []string
+}
+
+// NewSkillDanglingSinkCheck builds a check that scans the given sink
+// directories for dangling symlinks. staticSinks are the config-derived
+// agent sinks; gcOwnedRoots are the retired/managed roots (typically
+// materialize.LegacyOwnedRootsFor(cityPath)) whose dangling links
+// --fix may remove. liveSinksFn, when non-nil, is evaluated inside Run
+// so store-backed live-session enumeration does not slow check
+// construction or fail a doctor run that never reaches this check.
+func NewSkillDanglingSinkCheck(staticSinks []string, gcOwnedRoots []string, liveSinksFn func() []string) *SkillDanglingSinkCheck {
+ return &SkillDanglingSinkCheck{staticSinks: staticSinks, gcRoots: gcOwnedRoots, liveSinksFn: liveSinksFn}
+}
+
+// Name returns the check identifier.
+func (c *SkillDanglingSinkCheck) Name() string { return "skill-dangling-sink" }
+
+// danglingSinkLink records one dangling symlink found in a sink.
+type danglingSinkLink struct {
+ path string // absolute path of the symlink
+ target string // raw readlink target
+ gcOwned bool // target under a legacy/cache root — safe to remove
+}
+
+// scan walks every sink and returns the dangling links, deduplicated
+// and sorted by path. Missing sink directories are skipped silently —
+// an agent that never started has no sink and nothing to report.
+func (c *SkillDanglingSinkCheck) scan() []danglingSinkLink {
+ sinks := append([]string{}, c.staticSinks...)
+ if c.liveSinksFn != nil {
+ sinks = append(sinks, c.liveSinksFn()...)
+ }
+ seen := make(map[string]bool)
+ var out []danglingSinkLink
+ for _, sink := range sinks {
+ if sink == "" || seen[sink] {
+ continue
+ }
+ seen[sink] = true
+ entries, err := os.ReadDir(sink)
+ if err != nil {
+ continue
+ }
+ for _, de := range entries {
+ path := filepath.Join(sink, de.Name())
+ info, err := os.Lstat(path)
+ if err != nil || info.Mode()&os.ModeSymlink == 0 {
+ continue
+ }
+ target, err := os.Readlink(path)
+ if err != nil {
+ continue
+ }
+ // Dangling = following the link fails with not-exist.
+ // Other stat errors (permission, I/O) are inconclusive —
+ // never classify as dangling, so --fix cannot remove a
+ // link whose health we could not establish.
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ continue
+ }
+ out = append(out, danglingSinkLink{
+ path: path,
+ target: target,
+ gcOwned: materialize.TargetUnderManagedRoot(target, c.gcRoots),
+ })
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
+ return out
+}
+
+// Run reports a warning when any sink entry is a dangling symlink.
+func (c *SkillDanglingSinkCheck) Run(_ *CheckContext) *CheckResult {
+ r := &CheckResult{Name: c.Name()}
+ dangling := c.scan()
+ if len(dangling) == 0 {
+ r.Status = StatusOK
+ r.Message = "no dangling skill-sink symlinks"
+ return r
+ }
+ gcOwned := 0
+ details := make([]string, 0, len(dangling))
+ for _, d := range dangling {
+ class := "user-owned"
+ if d.gcOwned {
+ class = "gc-owned"
+ gcOwned++
+ }
+ details = append(details, fmt.Sprintf("%s -> %s (%s, dangling)", d.path, d.target, class))
+ }
+ r.Status = StatusWarning
+ r.Severity = SeverityAdvisory
+ r.Message = fmt.Sprintf("%d dangling skill-sink symlink(s) (%d gc-owned)", len(dangling), gcOwned)
+ r.Details = details
+ if gcOwned > 0 {
+ r.FixHint = "gc doctor --fix removes gc-owned dangling links; the next materialize pass recreates any still-desired link"
+ } else {
+ r.FixHint = "remove user-owned dangling links manually after confirming the target is truly retired"
+ }
+ return r
+}
+
+// CanFix returns true — gc-owned dangling links are safe to remove.
+func (c *SkillDanglingSinkCheck) CanFix() bool { return true }
+
+// WarmupEligible returns false — the scan is cheap but the live-session
+// sink enumeration opens the session store, which the `gc start`
+// warm-up path should not pay for.
+func (c *SkillDanglingSinkCheck) WarmupEligible() bool { return false }
+
+// Fix removes every gc-owned dangling symlink found by a fresh scan.
+// User-owned links are never touched. A re-scan (rather than cached Run
+// state) keeps the deletion decision current with the filesystem.
+func (c *SkillDanglingSinkCheck) Fix(_ *CheckContext) error {
+ var failed []string
+ for _, d := range c.scan() {
+ if !d.gcOwned {
+ continue
+ }
+ if err := os.Remove(d.path); err != nil {
+ failed = append(failed, fmt.Sprintf("%s: %v", d.path, err))
+ }
+ }
+ if len(failed) > 0 {
+ return fmt.Errorf("removing dangling gc-owned skill-sink links: %s", strings.Join(failed, "; "))
+ }
+ return nil
+}
diff --git a/internal/doctor/skill_dangling_sink_check_test.go b/internal/doctor/skill_dangling_sink_check_test.go
new file mode 100644
index 0000000000..84afec0947
--- /dev/null
+++ b/internal/doctor/skill_dangling_sink_check_test.go
@@ -0,0 +1,141 @@
+package doctor
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// mkSinkLink creates a symlink at sink/ -> target, creating the
+// sink directory. The target is never created — the link dangles.
+func mkDanglingLink(t *testing.T, sink, name, target string) {
+ t.Helper()
+ if err := os.MkdirAll(sink, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, filepath.Join(sink, name)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSkillDanglingSinkCheckClean(t *testing.T) {
+ t.Parallel()
+ sink := t.TempDir()
+ live := filepath.Join(t.TempDir(), "skill")
+ if err := os.MkdirAll(live, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(live, filepath.Join(sink, "gc-work")); err != nil {
+ t.Fatal(err)
+ }
+ c := NewSkillDanglingSinkCheck([]string{sink}, nil, nil)
+ r := c.Run(&CheckContext{})
+ if r.Status != StatusOK {
+ t.Fatalf("status = %v, want OK (%s)", r.Status, r.Message)
+ }
+}
+
+func TestSkillDanglingSinkCheckMissingSinkSkipped(t *testing.T) {
+ t.Parallel()
+ c := NewSkillDanglingSinkCheck([]string{filepath.Join(t.TempDir(), "no-such-sink")}, nil, nil)
+ if r := c.Run(&CheckContext{}); r.Status != StatusOK {
+ t.Fatalf("status = %v, want OK (%s)", r.Status, r.Message)
+ }
+}
+
+func TestSkillDanglingSinkCheckFlagsAndClassifies(t *testing.T) {
+ t.Parallel()
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ userRoot := filepath.Join(t.TempDir(), "user")
+ mkDanglingLink(t, sink, "core.gc-mail", filepath.Join(legacyRoot, "core", "skills", "gc-mail"))
+ mkDanglingLink(t, sink, "mine", filepath.Join(userRoot, "mine"))
+
+ c := NewSkillDanglingSinkCheck([]string{sink}, []string{legacyRoot}, nil)
+ r := c.Run(&CheckContext{})
+ if r.Status != StatusWarning {
+ t.Fatalf("status = %v, want warning", r.Status)
+ }
+ if r.Severity != SeverityAdvisory {
+ t.Errorf("severity = %v, want advisory", r.Severity)
+ }
+ if !strings.Contains(r.Message, "2 dangling") || !strings.Contains(r.Message, "1 gc-owned") {
+ t.Errorf("message = %q", r.Message)
+ }
+ if r.FixHint == "" {
+ t.Error("FixHint empty with gc-owned dangling links present")
+ }
+}
+
+func TestSkillDanglingSinkCheckFixRemovesOnlyGcOwned(t *testing.T) {
+ t.Parallel()
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ cacheRoot := filepath.Join(t.TempDir(), ".gc", "cache", "repos")
+ userRoot := filepath.Join(t.TempDir(), "user")
+ gcLegacy := filepath.Join(sink, "core.gc-mail")
+ gcCache := filepath.Join(sink, "core.gc-work")
+ userLink := filepath.Join(sink, "mine")
+ mkDanglingLink(t, sink, "core.gc-mail", filepath.Join(legacyRoot, "core", "skills", "gc-mail"))
+ mkDanglingLink(t, sink, "core.gc-work", filepath.Join(cacheRoot, "be555", "skills", "gc-work"))
+ mkDanglingLink(t, sink, "mine", filepath.Join(userRoot, "mine"))
+
+ c := NewSkillDanglingSinkCheck([]string{sink}, []string{legacyRoot, cacheRoot}, nil)
+ if err := c.Fix(&CheckContext{}); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{gcLegacy, gcCache} {
+ if _, err := os.Lstat(p); !os.IsNotExist(err) {
+ t.Errorf("gc-owned dangling link survived fix: %s (err=%v)", p, err)
+ }
+ }
+ if _, err := os.Lstat(userLink); err != nil {
+ t.Errorf("user-owned link removed by fix: %v", err)
+ }
+ // Post-fix run reports clean for gc-owned; the user link remains
+ // flagged but is not fixable.
+ r := c.Run(&CheckContext{})
+ if r.Status != StatusWarning || !strings.Contains(r.Message, "1 dangling") || !strings.Contains(r.Message, "0 gc-owned") {
+ t.Errorf("post-fix result = %v %q", r.Status, r.Message)
+ }
+}
+
+func TestSkillDanglingSinkCheckLiveSinksLazy(t *testing.T) {
+ t.Parallel()
+ staticSink := t.TempDir()
+ liveSink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ mkDanglingLink(t, liveSink, "core.gc-city", filepath.Join(legacyRoot, "core", "skills", "gc-city"))
+
+ calls := 0
+ c := NewSkillDanglingSinkCheck([]string{staticSink}, []string{legacyRoot}, func() []string {
+ calls++
+ return []string{liveSink}
+ })
+ if calls != 0 {
+ t.Fatal("liveSinksFn evaluated during construction")
+ }
+ r := c.Run(&CheckContext{})
+ if calls != 1 {
+ t.Fatalf("liveSinksFn called %d times, want 1", calls)
+ }
+ if r.Status != StatusWarning || !strings.Contains(r.Message, "1 dangling") {
+ t.Fatalf("result = %v %q", r.Status, r.Message)
+ }
+}
+
+func TestSkillDanglingSinkCheckDeduplicatesSinks(t *testing.T) {
+ t.Parallel()
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ mkDanglingLink(t, sink, "core.gc-mail", filepath.Join(legacyRoot, "core", "skills", "gc-mail"))
+
+ // Same sink via static list and live list (scope root == session
+ // workdir for stage-1-only agents) must report once.
+ c := NewSkillDanglingSinkCheck([]string{sink}, []string{legacyRoot}, func() []string { return []string{sink} })
+ r := c.Run(&CheckContext{})
+ if !strings.Contains(r.Message, "1 dangling") {
+ t.Fatalf("message = %q, want exactly one report", r.Message)
+ }
+}
diff --git a/internal/eventfeed/allowlist_drift_test.go b/internal/eventfeed/allowlist_drift_test.go
index 796125febb..fcfa00bf41 100644
--- a/internal/eventfeed/allowlist_drift_test.go
+++ b/internal/eventfeed/allowlist_drift_test.go
@@ -29,6 +29,8 @@ func TestAllowedTypesMatchEventConstants(t *testing.T) {
events.ConvoyClosed,
events.ControllerStarted,
events.EventsRotated,
+ events.ExecutionWorkAssociated,
+ events.ExecutionStepDefined,
events.SessionDrainAckedWithAssignedWork,
events.SessionResetStalled,
events.ProjectIdentityStamped,
diff --git a/internal/eventfeed/muxsource.go b/internal/eventfeed/muxsource.go
index 9d13a1d9e1..97ffaa5bf9 100644
--- a/internal/eventfeed/muxsource.go
+++ b/internal/eventfeed/muxsource.go
@@ -49,24 +49,33 @@ func NewMuxSource(providers func() map[string]events.Provider, cursors func() ma
// toExport projects a tagged event down to the exporter's closed primitive set.
// It forwards only envelope-safe fields (seq/type/time/actor/subject) plus the
-// two opaque correlation ids (run_id/session_id) the record site stamped onto
-// the typed Event fields; it never reads Payload or Message, so a payload-decode
-// can never reintroduce free-form content. The ids are safeRef-gated again in
-// ProjectEvent before egress.
+// opaque run/session correlation ids and native execution-step topology stamped
+// onto typed Event fields; it never reads Payload or Message, so a payload-decode
+// can never reintroduce free-form content. ProjectEvent validates each field at
+// egress.
func toExport(te events.TaggedEvent) eventexport.TaggedEvent {
return eventexport.TaggedEvent{
- City: te.City,
- Seq: te.Seq,
- Type: te.Type,
- Ts: te.Ts,
- Actor: te.Actor,
- Subject: te.Subject,
- RunID: te.RunID,
- SessionID: te.SessionID,
- StepID: te.StepID,
+ City: te.City,
+ Seq: te.Seq,
+ Type: te.Type,
+ Ts: te.Ts,
+ Actor: te.Actor,
+ Subject: te.Subject,
+ RunID: te.RunID,
+ SessionID: te.SessionID,
+ StepID: te.StepID,
+ DependsOnStepIDs: cloneStepDependencies(te.DependsOnStepIDs),
}
}
+func cloneStepDependencies(dependencies *[]string) *[]string {
+ if dependencies == nil {
+ return nil
+ }
+ clone := append([]string(nil), (*dependencies)...)
+ return &clone
+}
+
// Next yields the next tagged event, transparently rebuilding the multiplexer on
// the rebuild interval or when the current watcher ends.
func (s *MuxSource) Next(ctx context.Context) (eventexport.TaggedEvent, error) {
diff --git a/internal/eventfeed/muxsource_test.go b/internal/eventfeed/muxsource_test.go
index 762646a5fe..7d4ec1315d 100644
--- a/internal/eventfeed/muxsource_test.go
+++ b/internal/eventfeed/muxsource_test.go
@@ -314,20 +314,24 @@ func TestAdapter_NoLeakFromPayload(t *testing.T) {
// TestToExport_ForwardsTypedRunSession proves the adapter forwards the typed
// Event.RunID/SessionID (stamped at the record site) through to the projected
// envelope when EmitCorrelation is on.
-func TestToExport_ForwardsTypedRunSession(t *testing.T) {
+func TestToExport_ForwardsTypedRunSessionAndNativeTopology(t *testing.T) {
+ deps := []string{"step-a"}
te := events.TaggedEvent{
Event: events.Event{
Seq: 1, Type: "bead.closed", Ts: time.Date(2026, 6, 21, 10, 3, 27, 0, time.UTC),
- Actor: "cache-reconcile", Subject: "mc-1", RunID: "wf-root-abc", SessionID: "sess-9f2a",
+ Actor: "cache-reconcile", Subject: "mc-1", RunID: "wf-root-abc", SessionID: "sess-9f2a", StepID: "step-b", DependsOnStepIDs: &deps,
},
City: "c",
}
ex := toExport(te)
- if ex.RunID != "wf-root-abc" || ex.SessionID != "sess-9f2a" {
- t.Fatalf("toExport must forward typed run/session, got run=%q session=%q", ex.RunID, ex.SessionID)
+ if ex.RunID != "wf-root-abc" || ex.SessionID != "sess-9f2a" || ex.StepID != "step-b" || ex.DependsOnStepIDs == nil || (*ex.DependsOnStepIDs)[0] != "step-a" {
+ t.Fatalf("toExport must forward typed correlation/topology, got %+v", ex)
+ }
+ if ex.DependsOnStepIDs == &deps {
+ t.Fatal("toExport retained caller-owned topology slice")
}
env, ok := eventexport.ProjectEvent(ex, eventexport.Options{Salt: []byte("sixteen-byte-salt-xx"), ExportRef: true, EmitCorrelation: true})
- if !ok || env.RunID != "wf-root-abc" || env.SessionID != "sess-9f2a" {
- t.Fatalf("projected envelope must carry forwarded run/session, got %+v", env)
+ if !ok || env.RunID != "wf-root-abc" || env.SessionID != "sess-9f2a" || env.DependsOnStepIDs == nil || (*env.DependsOnStepIDs)[0] != "step-a" {
+ t.Fatalf("projected envelope must carry forwarded correlation/topology, got %+v", env)
}
}
diff --git a/internal/events/events.go b/internal/events/events.go
index 7e122fe9b3..c43a840663 100644
--- a/internal/events/events.go
+++ b/internal/events/events.go
@@ -32,6 +32,14 @@ const (
// Turns the otherwise-silent lost-claim race (RCA gc-typpc: one bead, four
// concurrent polecat claims) into an observable signal. ADR-0009.
BeadClaimRejected = "bead.claim_rejected"
+ // ExecutionWorkAssociated records an authoritative association between a
+ // graph.v2 workflow run and one physical input work bead. Subject carries
+ // the work bead and RunID carries the workflow root.
+ ExecutionWorkAssociated = "execution.work_associated"
+ // ExecutionStepDefined records one physical native execution-step
+ // occurrence. Subject carries the physical step bead, RunID the workflow
+ // root, and StepID/DependsOnStepIDs the semantic topology.
+ ExecutionStepDefined = "execution.step_defined"
// BeadDeadAssigneeReopened fires when the reconciler reopens a routed work
// bead whose assignee resolves to no open session bead — the owning session
// closed/retired while the bead stayed assigned, leaving it open+routed but
@@ -278,6 +286,7 @@ var KnownEventTypes = []string{
BeadWorktreeReaped, BeadWorktreeReapSkipped,
BeadClaimRejected,
BeadDeadAssigneeReopened,
+ ExecutionWorkAssociated, ExecutionStepDefined,
MailSent, MailRead, MailArchived, MailMarkedRead, MailMarkedUnread,
MailReplied, MailDeleted,
ConvoyCreated, ConvoyClosed,
@@ -334,6 +343,9 @@ type Event struct {
RunID string `json:"run_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
StepID string `json:"step_id,omitempty"`
+ // DependsOnStepIDs is nil for unknown native topology; a present empty
+ // slice represents a known root.
+ DependsOnStepIDs *[]string `json:"depends_on_step_ids,omitempty"`
}
// Recorder records events. Safe for concurrent use. Best-effort.
diff --git a/internal/events/eventstest/conformance.go b/internal/events/eventstest/conformance.go
index 76e5106d59..0936f2c843 100644
--- a/internal/events/eventstest/conformance.go
+++ b/internal/events/eventstest/conformance.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/gastownhall/gascity/internal/events"
+ "github.com/gastownhall/gascity/internal/testutil"
)
// rotatableProvider is the small interface a Provider must satisfy
@@ -733,7 +734,15 @@ func RunRotationTests(t *testing.T, newProvider func(t *testing.T) (events.Provi
// Phase 2: start a watcher BEFORE rotation. Drain any backlog
// so the watcher's offset is at end-of-active before we rotate.
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ //
+ // The watcher's own context is cancel-only, not deadline-bound:
+ // ForceRotate's fsync+rename and the gzip+reap behind res.Done below
+ // are this subtest's heaviest I/O, and sit between here and the
+ // post-rotate reads. A shared deadline charges that setup I/O
+ // against the read's budget instead of the read itself — nextWithin
+ // gives each blocking read its own fresh deadline so a slow disk
+ // slows the test instead of failing it.
+ ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w, err := p.Watch(ctx, 0)
if err != nil {
@@ -741,9 +750,24 @@ func RunRotationTests(t *testing.T, newProvider func(t *testing.T) (events.Provi
}
defer w.Close() //nolint:errcheck // test cleanup
+ nextWithin := func(d time.Duration) (events.Event, error) {
+ type result struct {
+ e events.Event
+ err error
+ }
+ ch := make(chan result, 1)
+ go func() { e, err := w.Next(); ch <- result{e, err} }()
+ select {
+ case r := <-ch:
+ return r.e, r.err
+ case <-time.After(d):
+ return events.Event{}, context.DeadlineExceeded
+ }
+ }
+
seen := make([]events.Event, 0, 5)
for i := 0; i < 5; i++ {
- e, err := w.Next()
+ e, err := nextWithin(testutil.GoroutineRaceTimeout)
if err != nil {
t.Fatalf("Next pre %d: %v", i, err)
}
@@ -776,7 +800,7 @@ func RunRotationTests(t *testing.T, newProvider func(t *testing.T) (events.Provi
// (c) The watcher should yield the anchor + the post-rotate
// events without gap.
for i := 0; i < 4; i++ { // 1 anchor + 3 post-rotate
- e, err := w.Next()
+ e, err := nextWithin(testutil.GoroutineRaceTimeout)
if err != nil {
t.Fatalf("Next post %d: %v", i, err)
}
diff --git a/internal/events/execution_payloads.go b/internal/events/execution_payloads.go
new file mode 100644
index 0000000000..9ba2a83d9e
--- /dev/null
+++ b/internal/events/execution_payloads.go
@@ -0,0 +1,6 @@
+package events
+
+func init() {
+ RegisterPayload(ExecutionWorkAssociated, NoPayload{})
+ RegisterPayload(ExecutionStepDefined, NoPayload{})
+}
diff --git a/internal/events/recorder.go b/internal/events/recorder.go
index c30d3fe3ff..f292ba0842 100644
--- a/internal/events/recorder.go
+++ b/internal/events/recorder.go
@@ -1,6 +1,7 @@
package events
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -215,31 +216,125 @@ func (r *FileRecorder) Record(e Event) {
// The bounded wait drops the recorder if a dead writer is holding the
// lock instead of blocking forever and piling up processes.
fd := int(r.file.Fd())
+ if err := lockRecorderFile(fd, r.path); err != nil {
+ fmt.Fprintf(r.stderr, "events: lock: %v\n", err) //nolint:errcheck // best-effort stderr
+ return
+ }
+ defer func() {
+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
+ fmt.Fprintf(r.stderr, "events: unlock: %v\n", err) //nolint:errcheck // best-effort stderr
+ }
+ }()
+
+ if err := r.writeRecordLocked(&e); err != nil {
+ fmt.Fprintf(r.stderr, "events: %v\n", err) //nolint:errcheck // best-effort stderr
+ }
+}
+
+// AppendBatch strictly appends a complete event batch under one mutex and one
+// cross-process file lock. It assigns contiguous sequence numbers, prepares the
+// complete JSONL payload before writing, performs exactly one write, and
+// returns every lock, marshal, write, or unlock failure to the caller.
+//
+// Unlike Record, AppendBatch is not best-effort and does not auto-rotate. It is
+// intended for bounded operator-authored snapshots whose caller must know
+// whether the complete append succeeded.
+func (r *FileRecorder) AppendBatch(batch []Event) (resultErr error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if r.closed {
+ return fmt.Errorf("recorder is closed")
+ }
+ if r.file == nil {
+ return fmt.Errorf("recorder file is unavailable")
+ }
+ if len(batch) == 0 {
+ return nil
+ }
+
+ fd := int(r.file.Fd())
+ if err := lockRecorderFile(fd, r.path); err != nil {
+ return fmt.Errorf("lock: %w", err)
+ }
+ unlockPending := true
+ defer func() {
+ if !unlockPending {
+ return
+ }
+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
+ resultErr = errors.Join(resultErr, fmt.Errorf("unlock: %w", err))
+ }
+ }()
+
+ latest, err := readLatestActiveSeq(r.path)
+ if err != nil {
+ return fmt.Errorf("latest seq: %w", err)
+ }
+ if r.seq > latest {
+ latest = r.seq
+ }
+ if uint64(len(batch)) > ^uint64(0)-latest {
+ return fmt.Errorf("allocating %d event sequences after %d: sequence overflow", len(batch), latest)
+ }
+
+ data, lastSeq, err := marshalBatch(batch, latest, time.Now())
+ if err != nil {
+ return err
+ }
+ if err := writeBatch(r.file, data); err != nil {
+ return fmt.Errorf("write: %w", err)
+ }
+ r.seq = lastSeq
+ r.recordCount += uint64(len(batch))
+
+ unlockPending = false
+ if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
+ return fmt.Errorf("unlock: %w", err)
+ }
+ return nil
+}
+
+func lockRecorderFile(fd int, path string) error {
deadline := time.Now().Add(recordFlockTimeout)
for {
err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)
if err == nil {
- break
+ return nil
}
if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) {
- fmt.Fprintf(r.stderr, "events: lock: %v\n", err) //nolint:errcheck // best-effort stderr
- return
+ return err
}
if time.Now().After(deadline) {
- fmt.Fprintf(r.stderr, "events: lock: timed out after %dms waiting on flock at %s\n", recordFlockTimeout.Milliseconds(), r.path) //nolint:errcheck // best-effort stderr
- return
+ return fmt.Errorf("timed out after %dms waiting on flock at %s", recordFlockTimeout.Milliseconds(), path)
}
time.Sleep(recordFlockRetryInterval)
}
- defer func() {
- if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
- fmt.Fprintf(r.stderr, "events: unlock: %v\n", err) //nolint:errcheck // best-effort stderr
+}
+
+func marshalBatch(batch []Event, startingSeq uint64, now time.Time) ([]byte, uint64, error) {
+ var data bytes.Buffer
+ for i, event := range batch {
+ event.Seq = startingSeq + uint64(i) + 1
+ if event.Ts.IsZero() {
+ event.Ts = now
}
- }()
+ encoded, err := json.Marshal(event)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshal event %d: %w", i, err)
+ }
+ data.Write(encoded)
+ data.WriteByte('\n')
+ }
+ return data.Bytes(), startingSeq + uint64(len(batch)), nil
+}
- if err := r.writeRecordLocked(&e); err != nil {
- fmt.Fprintf(r.stderr, "events: %v\n", err) //nolint:errcheck // best-effort stderr
+func writeBatch(writer io.Writer, data []byte) error {
+ written, err := writer.Write(data)
+ if written != len(data) {
+ return errors.Join(err, io.ErrShortWrite)
}
+ return err
}
// writeRecordLocked appends e to the active log under the recorder
diff --git a/internal/events/recorder_batch_test.go b/internal/events/recorder_batch_test.go
new file mode 100644
index 0000000000..26e290df53
--- /dev/null
+++ b/internal/events/recorder_batch_test.go
@@ -0,0 +1,121 @@
+package events
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFileRecorderAppendBatchWritesContiguousEvents(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "events.jsonl")
+ var stderr bytes.Buffer
+ recorder, err := NewFileRecorder(path, &stderr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = recorder.Close() })
+ recorder.Record(Event{Type: BeadCreated, Actor: "seed"})
+ explicit := time.Unix(123, 0).UTC()
+
+ if err := recorder.AppendBatch([]Event{
+ {Type: ExecutionWorkAssociated, Actor: "reemit", Subject: "work", RunID: "run"},
+ {Type: ExecutionStepDefined, Actor: "reemit", Subject: "step", RunID: "run", StepID: "build", Ts: explicit},
+ }); err != nil {
+ t.Fatalf("AppendBatch: %v", err)
+ }
+
+ got, err := ReadAll(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("events = %#v, want three", got)
+ }
+ if got[1].Seq != 2 || got[2].Seq != 3 {
+ t.Fatalf("batch sequences = %d,%d, want 2,3", got[1].Seq, got[2].Seq)
+ }
+ if got[1].Ts.IsZero() || !got[2].Ts.Equal(explicit) {
+ t.Fatalf("batch timestamps = %s,%s, want generated then %s", got[1].Ts, got[2].Ts, explicit)
+ }
+}
+
+func TestFileRecorderAppendBatchMarshalsEverythingBeforeWriting(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "events.jsonl")
+ recorder, err := NewFileRecorder(path, io.Discard)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = recorder.Close() })
+
+ err = recorder.AppendBatch([]Event{
+ {Type: ExecutionWorkAssociated, Actor: "reemit", Subject: "would-partially-land"},
+ {Type: ExecutionStepDefined, Actor: "reemit", Payload: json.RawMessage(`{`)},
+ })
+ if err == nil || !strings.Contains(err.Error(), "marshal") {
+ t.Fatalf("AppendBatch error = %v, want marshal error", err)
+ }
+ got, readErr := ReadAll(path)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if len(got) != 0 {
+ t.Fatalf("events = %#v, want no partial batch", got)
+ }
+}
+
+func TestFileRecorderAppendBatchSurfacesClosedAndLockErrors(t *testing.T) {
+ t.Run("closed", func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "events.jsonl")
+ recorder, err := NewFileRecorder(path, io.Discard)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := recorder.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := recorder.AppendBatch([]Event{{Type: ExecutionStepDefined}}); err == nil || !strings.Contains(err.Error(), "closed") {
+ t.Fatalf("AppendBatch error = %v, want closed error", err)
+ }
+ })
+
+ t.Run("lock", func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "events.jsonl")
+ recorder, err := NewFileRecorder(path, io.Discard)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = recorder.Close() })
+ sibling := mustOpenSiblingLock(t, path)
+ t.Cleanup(func() { _ = sibling.Close() })
+
+ err = recorder.AppendBatch([]Event{{Type: ExecutionStepDefined}})
+ if err == nil || !strings.Contains(err.Error(), "lock") {
+ t.Fatalf("AppendBatch error = %v, want lock error", err)
+ }
+ })
+}
+
+func TestWriteBatchDetectsShortWriteInOneCall(t *testing.T) {
+ writer := &shortBatchWriter{}
+ err := writeBatch(writer, []byte("complete batch"))
+ if !errors.Is(err, io.ErrShortWrite) {
+ t.Fatalf("writeBatch error = %v, want io.ErrShortWrite", err)
+ }
+ if writer.calls != 1 {
+ t.Fatalf("write calls = %d, want one", writer.calls)
+ }
+}
+
+type shortBatchWriter struct {
+ calls int
+}
+
+func (w *shortBatchWriter) Write(data []byte) (int, error) {
+ w.calls++
+ return len(data) - 1, nil
+}
diff --git a/internal/events/rotation_archive.go b/internal/events/rotation_archive.go
index a5ec520f49..15c1a8f0e4 100644
--- a/internal/events/rotation_archive.go
+++ b/internal/events/rotation_archive.go
@@ -135,13 +135,22 @@ func archiveOverlapsFilter(info archiveInfo, filter Filter) bool {
if filter.BeforeSeq > 0 && info.FirstSeq >= filter.BeforeSeq {
return false
}
- // Timestamp is the rotation instant — an upper bound on the archive's
- // newest event — so the archive is safe to skip only when that bound
- // predates Since. Never prune on Until: the archive's FIRST event time
- // is not recorded (only FirstSeq), so an archive stamped after Until
- // may still hold in-window events; pruning there would silently drop
- // them (vc-89s).
- if !filter.Since.IsZero() && info.Timestamp.Before(filter.Since) {
+ // Every event in an archive was appended to the live log before that
+ // log was rotated at true instant T, so event.Time <= T. But
+ // info.Timestamp is T truncated to whole seconds (archiveTimestampLayout
+ // has no sub-second component), so info.Timestamp <= T < info.Timestamp+1s
+ // — the true rotation instant, and therefore every event.Time, can land
+ // anywhere up to (but not including) the NEXT whole second. A Since
+ // inside that truncation window cannot be ruled out and must still be
+ // read; only a Since at or beyond info.Timestamp+1s is guaranteed to
+ // postdate every possible event.Time (#4628). A zero Timestamp carries
+ // no such guarantee (legacy basenames predate the stamped convention),
+ // so it is read. This also assumes event.Ts is never clamped forward of
+ // the true rotation instant by the recorder (see ga-da13nh follow-up).
+ // Until is deliberately not handled here: the filename records only the
+ // rotation instant, not the archive's first event, so there is no sound
+ // upper-bound skip.
+ if !filter.Since.IsZero() && !info.Timestamp.IsZero() && info.Timestamp.Add(time.Second).Before(filter.Since) {
return false
}
return true
diff --git a/internal/events/rotation_archive_test.go b/internal/events/rotation_archive_test.go
index 5c697ead58..b4c50e96aa 100644
--- a/internal/events/rotation_archive_test.go
+++ b/internal/events/rotation_archive_test.go
@@ -148,3 +148,72 @@ func TestArchiveOverlapsFilter(t *testing.T) {
})
}
}
+
+// TestArchiveOverlapsFilterSkipsArchivesOlderThanSince pins the skip-fast
+// contract for time-bounded reads: an archive whose rotation timestamp
+// predates filter.Since cannot contain a matching event, so the reader must
+// not gunzip it. The archive filename records only info.Timestamp, the
+// rotation instant TRUNCATED to whole seconds — the true rotation instant T
+// can land anywhere in [info.Timestamp, info.Timestamp+1s). Every event in
+// the archive was appended before T, so event.Time <= T, which only gives
+// event.Time < info.Timestamp+1s (see #4628). A Since strictly inside that
+// truncation second must therefore still be read; only a Since at or beyond
+// info.Timestamp+1s can be safely skipped.
+func TestArchiveOverlapsFilterSkipsArchivesOlderThanSince(t *testing.T) {
+ // Rotated 2026-05-07; the live fleet queries with ?since=5m.
+ info := archiveInfo{
+ Basename: "events.jsonl.archive-20260507T000000Z-seq-100-200.gz",
+ Timestamp: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
+ FirstSeq: 100,
+ LastSeq: 200,
+ }
+ tests := []struct {
+ name string
+ f Filter
+ want bool
+ }{
+ {
+ name: "Since well after archive rotation is skippable",
+ f: Filter{Since: time.Date(2026, 7, 24, 0, 0, 0, 0, time.UTC)},
+ want: false,
+ },
+ {
+ name: "Since one second after archive rotation must still be read (true rotation instant is unknown within the truncation second)",
+ f: Filter{Since: time.Date(2026, 5, 7, 0, 0, 1, 0, time.UTC)},
+ want: true,
+ },
+ {
+ name: "Since one second and one nanosecond after archive rotation is skippable",
+ f: Filter{Since: time.Date(2026, 5, 7, 0, 0, 1, 1, time.UTC)},
+ want: false,
+ },
+ {
+ name: "Since strictly inside the rotation's truncation second must still be read",
+ f: Filter{Since: time.Date(2026, 5, 7, 0, 0, 0, 500000000, time.UTC)},
+ want: true,
+ },
+ {
+ name: "Since before archive rotation must still be read",
+ f: Filter{Since: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)},
+ want: true,
+ },
+ {
+ name: "Since exactly at rotation must still be read (inclusive bound)",
+ f: Filter{Since: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC)},
+ want: true,
+ },
+ {
+ name: "zero Since is unbounded and must still be read",
+ f: Filter{},
+ want: true,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := archiveOverlapsFilter(info, tc.f); got != tc.want {
+ t.Errorf("archiveOverlapsFilter(Since=%v) = %v, want %v",
+ tc.f.Since, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/events/rotation_reader_test.go b/internal/events/rotation_reader_test.go
index d656df9f7c..3c34d282e3 100644
--- a/internal/events/rotation_reader_test.go
+++ b/internal/events/rotation_reader_test.go
@@ -2,6 +2,7 @@ package events
import (
"bytes"
+ "encoding/json"
"fmt"
"os"
"path/filepath"
@@ -400,6 +401,53 @@ func TestReadAllSurvivesMultipleRotations(t *testing.T) {
}
}
+// TestReadFilteredIncludesEventWithinArchiveSubSecondWindow pins the exact
+// silent-drop scenario from #4628: the archive filename records only the
+// whole-second-truncated rotation instant, so the true rotation (and any
+// event legitimately appended just before it) can land anywhere within that
+// truncation second. A Since inside that same second must still surface the
+// event rather than have the archive skip-fast past it ungunzipped. The
+// archive is built directly with a fixed rotation timestamp (not via a real
+// ForceRotate) so the test is deterministic and independent of wall-clock
+// timing.
+func TestReadFilteredIncludesEventWithinArchiveSubSecondWindow(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "events.jsonl")
+
+ // Filename truncates to the whole second; the true rotation instant (and
+ // the event inside the archive) can be anywhere in
+ // [rotationSecond, rotationSecond+1s) — here, 900ms in, mirroring the
+ // bug report's own worked example.
+ rotationSecond := time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)
+ eventTs := rotationSecond.Add(500 * time.Millisecond)
+
+ line, err := json.Marshal(Event{Seq: 1, Type: BeadCreated, Ts: eventTs, Actor: "human", Subject: "sub-second"})
+ if err != nil {
+ t.Fatalf("marshal event: %v", err)
+ }
+ src := filepath.Join(dir, "archive-source.jsonl")
+ if err := os.WriteFile(src, append(line, '\n'), 0o644); err != nil {
+ t.Fatalf("write archive source: %v", err)
+ }
+ archive := filepath.Join(dir, formatArchiveBasename(rotationSecond, 1, 1))
+ var stderr bytes.Buffer
+ if err := gzipAndArchive(src, archive, &stderr); err != nil {
+ t.Fatalf("gzipAndArchive: %v", err)
+ }
+
+ // Since falls after the filename's floored instant but before the
+ // event's actual sub-second timestamp — exactly the window the old
+ // skip-fast check misjudged.
+ since := rotationSecond.Add(250 * time.Millisecond)
+ got, err := ReadFiltered(path, Filter{Since: since})
+ if err != nil {
+ t.Fatalf("ReadFiltered: %v", err)
+ }
+ if len(got) != 1 || got[0].Seq != 1 {
+ t.Fatalf("ReadFiltered(Since=%v) = %v, want the sub-second event (seq 1)", since, got)
+ }
+}
+
func TestReadFilteredHandlesMissingArchiveDir(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "no-such-dir", "events.jsonl")
diff --git a/internal/executionevent/projector.go b/internal/executionevent/projector.go
new file mode 100644
index 0000000000..b5f02eef2e
--- /dev/null
+++ b/internal/executionevent/projector.go
@@ -0,0 +1,237 @@
+// Package executionevent projects authoritative graph execution facts from the
+// current graph and work stores.
+package executionevent
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+ "github.com/gastownhall/gascity/internal/beads"
+ convoycore "github.com/gastownhall/gascity/internal/convoy"
+ "github.com/gastownhall/gascity/internal/events"
+ "github.com/gastownhall/gascity/pkg/eventexport"
+)
+
+var (
+ // ErrNotGraphV2Root means the selected bead is not an authoritative graph.v2
+ // workflow root.
+ ErrNotGraphV2Root = errors.New("executionevent: root is not a graph.v2 workflow")
+ // ErrInvalidRootReference means the selected root cannot be represented as
+ // an opaque execution run reference.
+ ErrInvalidRootReference = errors.New("executionevent: invalid root reference")
+ // ErrInvalidConvoyReference means gc.input_convoy_id is present but cannot be
+ // represented as an opaque work reference.
+ ErrInvalidConvoyReference = errors.New("executionevent: invalid input convoy reference")
+)
+
+// WorkAssociation relates one physical input work bead to an execution run.
+type WorkAssociation struct {
+ WorkBeadID string
+ ExecutionRunID string
+}
+
+// StepDefinition describes one physical execution-step occurrence. A nil
+// DependsOnStepIDs means topology is unknown; a present empty slice identifies
+// an authoritative root step.
+type StepDefinition struct {
+ BeadID string
+ ExecutionRunID string
+ StepID string
+ DependsOnStepIDs *[]string
+}
+
+// Projection is the deterministic current-store execution projection for one
+// graph.v2 workflow root.
+type Projection struct {
+ WorkAssociations []WorkAssociation
+ Steps []StepDefinition
+}
+
+// EmitCurrent projects and records the current execution snapshot for rootID.
+// A nil recorder disables emission without reading either store.
+func EmitCurrent(recorder events.Recorder, graphStore beads.GraphStore, convoyStore beads.WorkStore, rootID, actor string) error {
+ if recorder == nil {
+ return nil
+ }
+ projection, err := ProjectCurrent(graphStore, convoyStore, rootID)
+ if err != nil {
+ return err
+ }
+ for _, event := range projection.Events(actor) {
+ recorder.Record(event)
+ }
+ return nil
+}
+
+// Events converts the projection to repeatable snapshot facts. Work
+// associations precede step definitions, preserving each slice's deterministic
+// order. Topology is copied so later graph reads cannot mutate emitted facts.
+func (p Projection) Events(actor string) []events.Event {
+ result := make([]events.Event, 0, len(p.WorkAssociations)+len(p.Steps))
+ for _, association := range p.WorkAssociations {
+ result = append(result, events.Event{
+ Type: events.ExecutionWorkAssociated,
+ Actor: actor,
+ Subject: association.WorkBeadID,
+ RunID: association.ExecutionRunID,
+ })
+ }
+ for _, step := range p.Steps {
+ result = append(result, events.Event{
+ Type: events.ExecutionStepDefined,
+ Actor: actor,
+ Subject: step.BeadID,
+ RunID: step.ExecutionRunID,
+ StepID: step.StepID,
+ DependsOnStepIDs: cloneTopology(step.DependsOnStepIDs),
+ })
+ }
+ return result
+}
+
+// ProjectCurrent projects current execution facts for rootID. The graph store
+// exclusively owns the workflow root and physical steps. When the root names an
+// input convoy, the supplied work store exclusively owns that convoy's tracks
+// edges. A graph run without an input convoy is valid and projects only steps.
+func ProjectCurrent(graphStore beads.GraphStore, convoyStore beads.WorkStore, rootID string) (Projection, error) {
+ if graphStore.Store == nil {
+ return Projection{}, fmt.Errorf("%w: nil graph store", ErrNotGraphV2Root)
+ }
+ if !eventexport.IsOpaqueRef(rootID) {
+ return Projection{}, fmt.Errorf("%w: %q", ErrInvalidRootReference, rootID)
+ }
+ root, err := graphStore.Get(rootID)
+ if err != nil {
+ return Projection{}, fmt.Errorf("loading workflow root %q: %w", rootID, err)
+ }
+ if root.Metadata[beadmeta.KindMetadataKey] != beadmeta.KindWorkflow ||
+ root.Metadata[beadmeta.FormulaContractMetadataKey] != beadmeta.FormulaContractGraphV2 {
+ return Projection{}, ErrNotGraphV2Root
+ }
+ if !eventexport.IsOpaqueRef(root.ID) {
+ return Projection{}, fmt.Errorf("%w: %q", ErrInvalidRootReference, root.ID)
+ }
+
+ steps, err := currentSteps(graphStore, root.ID)
+ if err != nil {
+ return Projection{}, err
+ }
+ convoyID := root.Metadata[beadmeta.InputConvoyIDMetadataKey]
+ if convoyID == "" {
+ return Projection{Steps: steps}, nil
+ }
+ work, err := currentWorkAssociations(convoyStore, root.ID, convoyID)
+ if err != nil {
+ return Projection{}, err
+ }
+ return Projection{WorkAssociations: work, Steps: steps}, nil
+}
+
+func currentWorkAssociations(store beads.WorkStore, rootID, convoyID string) ([]WorkAssociation, error) {
+ if !eventexport.IsOpaqueRef(convoyID) {
+ return nil, fmt.Errorf("%w: %q", ErrInvalidConvoyReference, convoyID)
+ }
+ if store.Store == nil {
+ return nil, fmt.Errorf("listing tracks membership for convoy %q: nil work store", convoyID)
+ }
+ dependencies, err := store.DepList(convoyID, "down")
+ if err != nil {
+ return nil, fmt.Errorf("listing tracks membership for convoy %q: %w", convoyID, err)
+ }
+ ids := make(map[string]struct{}, len(dependencies))
+ for _, dependency := range dependencies {
+ if dependency.Type != convoycore.TrackingDepType || dependency.IssueID != convoyID || !eventexport.IsOpaqueRef(dependency.DependsOnID) {
+ continue
+ }
+ ids[dependency.DependsOnID] = struct{}{}
+ }
+ sorted := make([]string, 0, len(ids))
+ for id := range ids {
+ sorted = append(sorted, id)
+ }
+ sort.Strings(sorted)
+ associations := make([]WorkAssociation, 0, len(sorted))
+ for _, id := range sorted {
+ associations = append(associations, WorkAssociation{WorkBeadID: id, ExecutionRunID: rootID})
+ }
+ return associations, nil
+}
+
+func currentSteps(store beads.GraphStore, rootID string) ([]StepDefinition, error) {
+ rows, err := store.ListByMetadata(
+ map[string]string{beadmeta.RootBeadIDMetadataKey: rootID},
+ 0,
+ beads.IncludeClosed,
+ beads.WithBothTiers,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("listing workflow steps for root %q: %w", rootID, err)
+ }
+ byID := make(map[string]beads.Bead, len(rows))
+ for _, row := range rows {
+ byID[row.ID] = row
+ }
+ ids := make([]string, 0, len(byID))
+ for id := range byID {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ steps := make([]StepDefinition, 0, len(ids))
+ for _, id := range ids {
+ row := byID[id]
+ if row.ID == rootID || !eventexport.IsOpaqueRef(row.ID) {
+ continue
+ }
+ stepID := row.Metadata[beadmeta.StepIDMetadataKey]
+ if !validNativeStepID(stepID) {
+ continue
+ }
+ steps = append(steps, StepDefinition{
+ BeadID: row.ID,
+ ExecutionRunID: rootID,
+ StepID: stepID,
+ DependsOnStepIDs: canonicalTopology(row.Metadata[beadmeta.NativeStepDependenciesMetadataKey], stepID),
+ })
+ }
+ return steps, nil
+}
+
+func canonicalTopology(raw, stepID string) *[]string {
+ if raw == "" || !validNativeStepID(stepID) {
+ return nil
+ }
+ var dependencies []string
+ if err := json.Unmarshal([]byte(raw), &dependencies); err != nil || dependencies == nil {
+ return nil
+ }
+ previous := ""
+ for _, dependency := range dependencies {
+ if !validNativeStepID(dependency) || dependency == stepID || (previous != "" && dependency <= previous) {
+ return nil
+ }
+ previous = dependency
+ }
+ canonical, err := json.Marshal(dependencies)
+ if err != nil || string(canonical) != raw {
+ return nil
+ }
+ return &dependencies
+}
+
+func validNativeStepID(id string) bool {
+ return strings.TrimSpace(id) != "" && len(id) <= 256 && utf8.ValidString(id)
+}
+
+func cloneTopology(dependencies *[]string) *[]string {
+ if dependencies == nil {
+ return nil
+ }
+ clone := make([]string, len(*dependencies))
+ copy(clone, *dependencies)
+ return &clone
+}
diff --git a/internal/executionevent/projector_test.go b/internal/executionevent/projector_test.go
new file mode 100644
index 0000000000..073639e21f
--- /dev/null
+++ b/internal/executionevent/projector_test.go
@@ -0,0 +1,284 @@
+package executionevent
+
+import (
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+ "github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/events"
+)
+
+func TestProjectCurrentUsesOnlyTracksFromConvoyStore(t *testing.T) {
+ graph := beads.NewMemStore()
+ work := beads.NewMemStore()
+ convoy := mustCreateProjectionBead(t, work, beads.Bead{ID: "mc-convoy", Type: "convoy"})
+ tracked := mustCreateProjectionBead(t, work, beads.Bead{ID: "mc-tracked"})
+ metadataOnly := mustCreateProjectionBead(t, work, beads.Bead{
+ ID: "mc-metadata",
+ Metadata: map[string]string{
+ "legacy.tracking_convoy_id": convoy.ID,
+ },
+ })
+ parentChild := mustCreateProjectionBead(t, work, beads.Bead{ID: "mc-parent-child"})
+ if err := work.DepAdd(convoy.ID, tracked.ID, "tracks"); err != nil {
+ t.Fatalf("add tracks edge: %v", err)
+ }
+ if err := work.DepAdd(convoy.ID, parentChild.ID, "parent-child"); err != nil {
+ t.Fatalf("add parent-child edge: %v", err)
+ }
+ root := mustCreateProjectionRoot(t, graph, convoy.ID)
+
+ got, err := ProjectCurrent(
+ beads.GraphStore{Store: graph},
+ beads.WorkStore{Store: work},
+ root.ID,
+ )
+ if err != nil {
+ t.Fatalf("ProjectCurrent: %v", err)
+ }
+ want := []WorkAssociation{{WorkBeadID: tracked.ID, ExecutionRunID: root.ID}}
+ if !reflect.DeepEqual(got.WorkAssociations, want) {
+ t.Fatalf("work associations = %#v, want %#v (metadata=%s parent-child=%s)", got.WorkAssociations, want, metadataOnly.ID, parentChild.ID)
+ }
+}
+
+func TestProjectCurrentRetainsDanglingOpaqueTrackedID(t *testing.T) {
+ graph := beads.NewMemStore()
+ work := beads.NewMemStore()
+ root := mustCreateProjectionRoot(t, graph, "mc-convoy")
+ store := projectionDepStore{
+ Store: work,
+ convoyID: "mc-convoy",
+ deps: []beads.Dep{
+ {IssueID: "mc-convoy", DependsOnID: "mc-dangling", Type: "tracks"},
+ {IssueID: "mc-other", DependsOnID: "mc-wrong-source", Type: "tracks"},
+ {IssueID: "mc-convoy", DependsOnID: "MC invalid", Type: "tracks"},
+ },
+ }
+
+ got, err := ProjectCurrent(
+ beads.GraphStore{Store: graph},
+ beads.WorkStore{Store: store},
+ root.ID,
+ )
+ if err != nil {
+ t.Fatalf("ProjectCurrent: %v", err)
+ }
+ want := []WorkAssociation{{WorkBeadID: "mc-dangling", ExecutionRunID: root.ID}}
+ if !reflect.DeepEqual(got.WorkAssociations, want) {
+ t.Fatalf("work associations = %#v, want %#v", got.WorkAssociations, want)
+ }
+}
+
+func TestProjectCurrentSortsFactsAndPreservesPhysicalAttempts(t *testing.T) {
+ graph := beads.NewMemStore()
+ work := beads.NewMemStore()
+ root := mustCreateProjectionRoot(t, graph, "mc-convoy")
+ stepZ := mustCreateProjectionStep(t, graph, "gcg-step-z", root.ID, "build", `["prepare"]`)
+ stepA := mustCreateProjectionStep(t, graph, "gcg-step-a", root.ID, "build", `["prepare"]`)
+ closed := "closed"
+ if err := graph.Update(stepZ.ID, beads.UpdateOpts{Status: &closed}); err != nil {
+ t.Fatalf("close physical attempt: %v", err)
+ }
+ store := projectionDepStore{
+ Store: work,
+ convoyID: "mc-convoy",
+ deps: []beads.Dep{
+ {IssueID: "mc-convoy", DependsOnID: "mc-work-z", Type: "tracks"},
+ {IssueID: "mc-convoy", DependsOnID: "mc-work-a", Type: "tracks"},
+ {IssueID: "mc-convoy", DependsOnID: "mc-work-z", Type: "tracks"},
+ },
+ }
+
+ got, err := ProjectCurrent(
+ beads.GraphStore{Store: graph},
+ beads.WorkStore{Store: store},
+ root.ID,
+ )
+ if err != nil {
+ t.Fatalf("ProjectCurrent: %v", err)
+ }
+ wantWork := []WorkAssociation{
+ {WorkBeadID: "mc-work-a", ExecutionRunID: root.ID},
+ {WorkBeadID: "mc-work-z", ExecutionRunID: root.ID},
+ }
+ if !reflect.DeepEqual(got.WorkAssociations, wantWork) {
+ t.Fatalf("work associations = %#v, want %#v", got.WorkAssociations, wantWork)
+ }
+ wantSteps := []StepDefinition{
+ {BeadID: stepA.ID, ExecutionRunID: root.ID, StepID: "build", DependsOnStepIDs: projectionStringsPtr([]string{"prepare"})},
+ {BeadID: stepZ.ID, ExecutionRunID: root.ID, StepID: "build", DependsOnStepIDs: projectionStringsPtr([]string{"prepare"})},
+ }
+ sort.Slice(wantSteps, func(i, j int) bool { return wantSteps[i].BeadID < wantSteps[j].BeadID })
+ if !reflect.DeepEqual(got.Steps, wantSteps) {
+ t.Fatalf("steps = %#v, want %#v", got.Steps, wantSteps)
+ }
+}
+
+func TestProjectCurrentMissingInputConvoyStillProjectsSteps(t *testing.T) {
+ graph := beads.NewMemStore()
+ root := mustCreateProjectionRoot(t, graph, "")
+ step := mustCreateProjectionStep(t, graph, "gcg-step", root.ID, "build", "[]")
+
+ got, err := ProjectCurrent(
+ beads.GraphStore{Store: graph},
+ beads.WorkStore{},
+ root.ID,
+ )
+ if err != nil {
+ t.Fatalf("ProjectCurrent: %v", err)
+ }
+ if len(got.WorkAssociations) != 0 {
+ t.Fatalf("work associations = %#v, want none", got.WorkAssociations)
+ }
+ want := []StepDefinition{{
+ BeadID: step.ID,
+ ExecutionRunID: root.ID,
+ StepID: "build",
+ DependsOnStepIDs: projectionStringsPtr([]string{}),
+ }}
+ if !reflect.DeepEqual(got.Steps, want) {
+ t.Fatalf("steps = %#v, want %#v", got.Steps, want)
+ }
+}
+
+func TestProjectCurrentPreservesTopologyTriState(t *testing.T) {
+ graph := beads.NewMemStore()
+ root := mustCreateProjectionRoot(t, graph, "")
+ invalid := mustCreateProjectionStep(t, graph, "gcg-step-invalid", root.ID, "invalid", `["z","a"]`)
+ invalidWhitespace := mustCreateProjectionStep(t, graph, "gcg-step-invalid-whitespace", root.ID, "whitespace-dep", `[" "]`)
+ known := mustCreateProjectionStep(t, graph, "gcg-step-known", root.ID, "known", `["root"]`)
+ rootStep := mustCreateProjectionStep(t, graph, "gcg-step-root", root.ID, "root", "[]")
+ unknown := mustCreateProjectionStep(t, graph, "gcg-step-unknown", root.ID, "unknown", "")
+ mustCreateProjectionStep(t, graph, "gcg-step-blank-id", root.ID, " ", "[]")
+
+ got, err := ProjectCurrent(beads.GraphStore{Store: graph}, beads.WorkStore{}, root.ID)
+ if err != nil {
+ t.Fatalf("ProjectCurrent: %v", err)
+ }
+ want := []StepDefinition{
+ {BeadID: invalid.ID, ExecutionRunID: root.ID, StepID: "invalid"},
+ {BeadID: invalidWhitespace.ID, ExecutionRunID: root.ID, StepID: "whitespace-dep"},
+ {BeadID: known.ID, ExecutionRunID: root.ID, StepID: "known", DependsOnStepIDs: projectionStringsPtr([]string{"root"})},
+ {BeadID: rootStep.ID, ExecutionRunID: root.ID, StepID: "root", DependsOnStepIDs: projectionStringsPtr([]string{})},
+ {BeadID: unknown.ID, ExecutionRunID: root.ID, StepID: "unknown"},
+ }
+ sort.Slice(want, func(i, j int) bool { return want[i].BeadID < want[j].BeadID })
+ if !reflect.DeepEqual(got.Steps, want) {
+ t.Fatalf("steps = %#v, want %#v", got.Steps, want)
+ }
+}
+
+func TestProjectCurrentRejectsNonGraphV2Root(t *testing.T) {
+ graph := beads.NewMemStore()
+ plain := mustCreateProjectionBead(t, graph, beads.Bead{ID: "gcg-plain"})
+ if _, err := ProjectCurrent(beads.GraphStore{Store: graph}, beads.WorkStore{}, plain.ID); err == nil {
+ t.Fatal("ProjectCurrent accepted a non-graph.v2 root")
+ }
+}
+
+func TestProjectionEventsPreserveFactsAndRepeatSnapshots(t *testing.T) {
+ rootTopology := []string{}
+ dependentTopology := []string{"root"}
+ projection := Projection{
+ WorkAssociations: []WorkAssociation{
+ {WorkBeadID: "mc-a", ExecutionRunID: "gcg-root"},
+ {WorkBeadID: "mc-b", ExecutionRunID: "gcg-root"},
+ },
+ Steps: []StepDefinition{
+ {BeadID: "gcg-step-a", ExecutionRunID: "gcg-root", StepID: "root", DependsOnStepIDs: &rootTopology},
+ {BeadID: "gcg-step-b", ExecutionRunID: "gcg-root", StepID: "build", DependsOnStepIDs: &dependentTopology},
+ },
+ }
+ want := []events.Event{
+ {Type: events.ExecutionWorkAssociated, Actor: "graph-projector", Subject: "mc-a", RunID: "gcg-root"},
+ {Type: events.ExecutionWorkAssociated, Actor: "graph-projector", Subject: "mc-b", RunID: "gcg-root"},
+ {Type: events.ExecutionStepDefined, Actor: "graph-projector", Subject: "gcg-step-a", RunID: "gcg-root", StepID: "root", DependsOnStepIDs: projectionStringsPtr([]string{})},
+ {Type: events.ExecutionStepDefined, Actor: "graph-projector", Subject: "gcg-step-b", RunID: "gcg-root", StepID: "build", DependsOnStepIDs: projectionStringsPtr([]string{"root"})},
+ }
+
+ first := projection.Events("graph-projector")
+ second := projection.Events("graph-projector")
+ if !reflect.DeepEqual(first, want) || !reflect.DeepEqual(second, want) {
+ t.Fatalf("repeated snapshot events = %#v / %#v, want %#v", first, second, want)
+ }
+ dependentTopology[0] = "mutated"
+ if first[3].DependsOnStepIDs == projection.Steps[1].DependsOnStepIDs || (*first[3].DependsOnStepIDs)[0] != "root" {
+ t.Fatalf("event retained mutable projector topology: %#v", first[3].DependsOnStepIDs)
+ }
+}
+
+func TestEmitCurrentProjectsAndRecordsSnapshotFacts(t *testing.T) {
+ graph := beads.NewMemStore()
+ root := mustCreateProjectionRoot(t, graph, "")
+ step := mustCreateProjectionStep(t, graph, "gcg-step", root.ID, "build", "[]")
+ recorder := events.NewFake()
+
+ if err := EmitCurrent(recorder, beads.GraphStore{Store: graph}, beads.WorkStore{}, root.ID, "formula-cook"); err != nil {
+ t.Fatalf("EmitCurrent: %v", err)
+ }
+
+ if len(recorder.Events) != 1 {
+ t.Fatalf("recorded events = %#v, want one", recorder.Events)
+ }
+ got := recorder.Events[0]
+ if got.Type != events.ExecutionStepDefined || got.Actor != "formula-cook" || got.Subject != step.ID || got.RunID != root.ID || got.StepID != "build" {
+ t.Fatalf("recorded event = %#v, want projected step fact", got)
+ }
+}
+
+func TestEmitCurrentNilRecorderIsNoOp(t *testing.T) {
+ if err := EmitCurrent(nil, beads.GraphStore{}, beads.WorkStore{}, "missing", "formula-cook"); err != nil {
+ t.Fatalf("EmitCurrent with nil recorder: %v", err)
+ }
+}
+
+func mustCreateProjectionRoot(t *testing.T, store beads.Store, convoyID string) beads.Bead {
+ t.Helper()
+ metadata := map[string]string{
+ beadmeta.KindMetadataKey: beadmeta.KindWorkflow,
+ beadmeta.FormulaContractMetadataKey: beadmeta.FormulaContractGraphV2,
+ }
+ if convoyID != "" {
+ metadata[beadmeta.InputConvoyIDMetadataKey] = convoyID
+ }
+ return mustCreateProjectionBead(t, store, beads.Bead{Metadata: metadata})
+}
+
+func mustCreateProjectionStep(t *testing.T, store beads.Store, id, rootID, stepID, topology string) beads.Bead {
+ t.Helper()
+ metadata := map[string]string{
+ beadmeta.RootBeadIDMetadataKey: rootID,
+ beadmeta.StepIDMetadataKey: stepID,
+ }
+ if topology != "" {
+ metadata[beadmeta.NativeStepDependenciesMetadataKey] = topology
+ }
+ return mustCreateProjectionBead(t, store, beads.Bead{ID: id, Metadata: metadata})
+}
+
+func mustCreateProjectionBead(t *testing.T, store beads.Store, bead beads.Bead) beads.Bead {
+ t.Helper()
+ created, err := store.Create(bead)
+ if err != nil {
+ t.Fatalf("create %s: %v", bead.ID, err)
+ }
+ return created
+}
+
+func projectionStringsPtr(values []string) *[]string { return &values }
+
+type projectionDepStore struct {
+ beads.Store
+ convoyID string
+ deps []beads.Dep
+}
+
+func (s projectionDepStore) DepList(id, direction string) ([]beads.Dep, error) {
+ if id != s.convoyID || direction != "down" {
+ return nil, nil
+ }
+ return append([]beads.Dep(nil), s.deps...), nil
+}
diff --git a/internal/executionevent/testenv_import_test.go b/internal/executionevent/testenv_import_test.go
new file mode 100644
index 0000000000..efd2e9710a
--- /dev/null
+++ b/internal/executionevent/testenv_import_test.go
@@ -0,0 +1,5 @@
+// Code generated by go run scripts/add-testenv-import.go; DO NOT EDIT.
+
+package executionevent
+
+import _ "github.com/gastownhall/gascity/internal/testenv"
diff --git a/internal/formula/parser.go b/internal/formula/parser.go
index 7053630174..c2fcdf7f18 100644
--- a/internal/formula/parser.go
+++ b/internal/formula/parser.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/BurntSushi/toml"
+ "github.com/gastownhall/gascity/internal/pathutil"
)
// Formula file extensions. Canonical TOML is preferred, infixed TOML remains
@@ -206,10 +207,7 @@ func (p *Parser) parseResolvedAt(data []byte, absPath, label string) (*Formula,
}
func descriptionFileBaseDir(path string) string {
- if resolved, err := filepath.EvalSymlinks(path); err == nil {
- return filepath.Dir(resolved)
- }
- return filepath.Dir(path)
+ return filepath.Dir(pathutil.NormalizePathForCompare(path))
}
// Parse parses a formula from JSON bytes.
diff --git a/internal/formula/parser_test.go b/internal/formula/parser_test.go
index 4e41ca90bd..2e0cab05a5 100644
--- a/internal/formula/parser_test.go
+++ b/internal/formula/parser_test.go
@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/gastownhall/gascity/internal/testutil"
)
func TestParse_BasicFormula(t *testing.T) {
@@ -3607,3 +3609,36 @@ title = "Do work"
t.Errorf("error missing '1..{n}' (single-brace form, guards against double-brace regression): %v", err)
}
}
+
+// TestDescriptionFileBaseDirResolvesSymlinkedParentWithMissingLeaf pins the
+// ga-iawy13.6 canonical-path-at-ingest fix: descriptionFileBaseDir must
+// resolve through a symlinked parent directory even when the path itself
+// (e.g. a ParseTOMLAt source path whose bytes were never written to disk)
+// does not exist. Today it only attempts to resolve the full path and
+// falls back to the unresolved parent on failure, with no walk-up at all.
+func TestDescriptionFileBaseDirResolvesSymlinkedParentWithMissingLeaf(t *testing.T) {
+ root := t.TempDir()
+ realDir := filepath.Join(root, "real")
+ if err := os.MkdirAll(realDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ aliasDir := filepath.Join(root, "alias")
+ if err := os.Symlink(realDir, aliasDir); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+
+ missing := filepath.Join(aliasDir, "not-yet-created.toml")
+ got := descriptionFileBaseDir(missing)
+
+ want, err := filepath.EvalSymlinks(aliasDir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(aliasDir): %v", err)
+ }
+ // Compared via testutil.AssertSamePath rather than ==: the expectation is
+ // built with bare filepath.EvalSymlinks, but the function under test
+ // normalizes through pathutil, which on darwin collapses the /private/var
+ // and /private/tmp host aliases back to /var and /tmp — the reverse
+ // direction from EvalSymlinks. The two spellings denote the same file, so
+ // a raw compare fails on a correct result (macOS only; CI is Linux).
+ testutil.AssertCanonicalPathEquals(t, got, want)
+}
diff --git a/internal/formula/source.go b/internal/formula/source.go
index 47455b5ba4..377f75c706 100644
--- a/internal/formula/source.go
+++ b/internal/formula/source.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gastownhall/gascity/internal/git"
+ "github.com/gastownhall/gascity/internal/pathutil"
)
// Source abstracts how formula files are located and read. The default
@@ -150,15 +151,7 @@ func (g *GitRefSource) repoTopAndRelPath(path string) (string, string, bool) {
}
func canonicalExistingPath(path string) string {
- path = filepath.Clean(path)
- if resolved, err := filepath.EvalSymlinks(path); err == nil {
- return filepath.Clean(resolved)
- }
- dir := filepath.Dir(path)
- if resolved, err := filepath.EvalSymlinks(dir); err == nil {
- return filepath.Join(filepath.Clean(resolved), filepath.Base(path))
- }
- return path
+ return pathutil.NormalizePathForCompare(path)
}
// Stat reports whether a regular blob exists at the configured ref
diff --git a/internal/formula/source_test.go b/internal/formula/source_test.go
index 6656144f77..7d19e06a19 100644
--- a/internal/formula/source_test.go
+++ b/internal/formula/source_test.go
@@ -8,6 +8,8 @@ import (
"sort"
"strings"
"testing"
+
+ "github.com/gastownhall/gascity/internal/testutil"
)
// TestFSSourceMatchesLegacyBehavior asserts FSSource is a faithful
@@ -585,3 +587,38 @@ func derefString(s *string) string {
}
return *s
}
+
+// TestCanonicalExistingPathResolvesSymlinkedGrandparentWithTwoMissingLevels
+// pins the ga-iawy13.6 canonical-path-at-ingest fix: canonicalExistingPath
+// must walk up past more than one missing path component to find a
+// resolvable symlinked ancestor, matching pathutil.NormalizePathForCompare.
+// Today it only tries the immediate parent, so a path missing at both the
+// leaf and the immediate-parent level resolves through the unresolved
+// symlink instead of its real target.
+func TestCanonicalExistingPathResolvesSymlinkedGrandparentWithTwoMissingLevels(t *testing.T) {
+ root := t.TempDir()
+ realDir := filepath.Join(root, "real")
+ if err := os.MkdirAll(realDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ aliasDir := filepath.Join(root, "alias")
+ if err := os.Symlink(realDir, aliasDir); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+
+ missing := filepath.Join(aliasDir, "missing-parent", "missing-leaf")
+ got := canonicalExistingPath(missing)
+
+ resolvedAlias, err := filepath.EvalSymlinks(aliasDir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(aliasDir): %v", err)
+ }
+ want := filepath.Join(resolvedAlias, "missing-parent", "missing-leaf")
+ // Compared via testutil.AssertSamePath rather than ==: the expectation is
+ // built with bare filepath.EvalSymlinks, but the function under test
+ // normalizes through pathutil, which on darwin collapses the /private/var
+ // and /private/tmp host aliases back to /var and /tmp — the reverse
+ // direction from EvalSymlinks. The two spellings denote the same file, so
+ // a raw compare fails on a correct result (macOS only; CI is Linux).
+ testutil.AssertCanonicalPathEquals(t, got, want)
+}
diff --git a/internal/gitcred/rules.go b/internal/gitcred/rules.go
index f25f94a555..386ef81de4 100644
--- a/internal/gitcred/rules.go
+++ b/internal/gitcred/rules.go
@@ -3,6 +3,7 @@ package gitcred
import (
"errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"runtime"
@@ -34,7 +35,8 @@ const credentialsFileName = "credentials.toml"
// fallback.
const commandLayerOrigin = "$" + EnvCredentialCommand
-// ErrInsecurePermissions reports a credentials file readable by group or other.
+// ErrInsecurePermissions reports a credentials file whose mode exposes it
+// beyond its owner. secureMode is the exact predicate.
var ErrInsecurePermissions = errors.New("credentials file is group/world accessible")
// Rule is one [[credential]] entry. Exactly one pointer field (Helper,
@@ -86,8 +88,10 @@ type credentialsFile struct {
// 3. $GC_HOME/credentials.toml — gchome.Default().
// 4. $GC_GIT_CREDENTIAL_COMMAND — recorded as a rule-less fallback layer.
//
-// Every file present must be 0600/0400 (no group/other bits; the check is
-// skipped on Windows) or Load returns ErrInsecurePermissions wrapping the path.
+// Every file present must be owner-only — 0600/0400, or the root-owned
+// own-group 0440 a Kubernetes Secret volume mount produces (see secureMode);
+// the check is skipped on Windows. Otherwise Load returns
+// ErrInsecurePermissions wrapping the path.
// Missing files are not errors. A literal "token"/"password" key, or a rule
// with zero or more than one pointer field, is a hard parse error.
func Load(cityRoot string) (*Rules, error) {
@@ -187,7 +191,7 @@ func loadFileLayer(path string) (*layer, error) {
}
return nil, fmt.Errorf("reading credentials file %q: %w", path, err)
}
- if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
+ if runtime.GOOS != "windows" && !fileModeSecure(info) {
return nil, fmt.Errorf("%w: %s", ErrInsecurePermissions, path)
}
data, err := os.ReadFile(path)
@@ -213,6 +217,49 @@ func loadFileLayer(path string) (*layer, error) {
return lyr, nil
}
+// unknownID stands in for an owner we could not read. It is (uid_t)-1, which no
+// file is ever owned by, so an unreadable owner fails the group-read exemption
+// in secureMode closed.
+const unknownID = ^uint32(0)
+
+// fileModeSecure reports whether a credentials file's mode is safe to load.
+// Ownership comes from the platform statOwner; when the FileInfo carries none,
+// the file is treated as foreign-owned.
+func fileModeSecure(info fs.FileInfo) bool {
+ uid, gid, ok := statOwner(info)
+ if !ok {
+ uid, gid = unknownID, unknownID
+ }
+ return secureMode(info.Mode().Perm(), uid, gid, uint32(os.Getegid()))
+}
+
+// secureMode is the permission gate for a credentials file. Owner bits are
+// unrestricted; every world bit and every group write/exec bit is rejected.
+//
+// Group READ is accepted only for the exact shape kubelet produces for a Secret
+// volume mounted with fsGroup: owned by root — Secret volume files always are,
+// there is no fsUser — group-owned by our own effective gid, group bits exactly
+// r--. That exemption is what lets the reader consume the Secret mount directly.
+// The alternative is copying the Secret into an emptyDir at init, which freezes
+// the credentials for the pod's whole lifetime: kubelet can atomically rotate a
+// Secret volume, but it cannot rotate a copy.
+//
+// The exemption grants an attacker nothing: reading the file already requires
+// membership in our own primary group, and the rules file holds no secrets —
+// only match patterns, usernames, and token_file paths. The tokens themselves
+// live in the files those paths name (resolve.go). A user-owned 0640 file is
+// still rejected, because your own files are never root-owned: off-cluster
+// behavior is identical to the strict 0o077 check this replaced.
+func secureMode(perm fs.FileMode, uid, gid, egid uint32) bool {
+ if perm&0o007 != 0 || perm&0o030 != 0 {
+ return false
+ }
+ if perm&0o040 != 0 {
+ return uid == 0 && gid == egid
+ }
+ return true
+}
+
// ruleFromRaw converts a decoded [[credential]] table into a validated Rule. It
// rejects literal secret keys and enforces exactly-one-pointer cardinality.
func ruleFromRaw(raw map[string]any) (Rule, error) {
diff --git a/internal/gitcred/rules_test.go b/internal/gitcred/rules_test.go
index eddfeeae38..dc0ebab674 100644
--- a/internal/gitcred/rules_test.go
+++ b/internal/gitcred/rules_test.go
@@ -2,6 +2,7 @@ package gitcred
import (
"errors"
+ "io/fs"
"os"
"path/filepath"
"runtime"
@@ -119,6 +120,92 @@ func TestLoadInsecurePermissions(t *testing.T) {
}
}
+func TestSecureMode(t *testing.T) {
+ const egid = 1001
+ const me = 1001
+ tests := []struct {
+ name string
+ perm fs.FileMode
+ uid uint32
+ gid uint32
+ want bool
+ }{
+ {"owner read only", 0o400, me, egid, true},
+ {"owner read write", 0o600, me, egid, true},
+ {"kubernetes secret mount", 0o440, 0, egid, true},
+ {"world readable", 0o644, 0, egid, false},
+ {"world readable owner only otherwise", 0o404, me, egid, false},
+ {"group writable", 0o660, 0, egid, false},
+ {"group executable", 0o450, 0, egid, false},
+ {"group readable foreign gid", 0o440, 0, egid + 1, false},
+ {"group readable not root owned", 0o440, me, egid, false},
+ {"group readable owner unknown", 0o440, unknownID, unknownID, false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := secureMode(tc.perm, tc.uid, tc.gid, egid); got != tc.want {
+ t.Fatalf("secureMode(%v, uid=%d, gid=%d, egid=%d) = %v, want %v",
+ tc.perm, tc.uid, tc.gid, egid, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestLoadRejectsUserOwnedGroupRead(t *testing.T) {
+ // The group-read exemption is for root-owned Secret mounts only. A 0640
+ // file the user created themselves is still insecure, which is what keeps
+ // laptop and CI behavior identical to the pre-exemption check.
+ if runtime.GOOS == "windows" {
+ t.Skip("permission bits are POSIX-only")
+ }
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: a file we create is root-owned and would be exempt")
+ }
+ city := t.TempDir()
+ t.Setenv("GC_HOME", t.TempDir())
+ t.Setenv(EnvCredentialsFile, "")
+ t.Setenv("GITHUB_TOKEN", "")
+ t.Setenv("GH_TOKEN", "")
+ t.Setenv(EnvCredentialCommand, "")
+ writeCredFile(t, filepath.Join(city, ".gc", "credentials.toml"), "[[credential]]\nmatch=\"a.com\"\nhelper=\"x\"\n", 0o640)
+
+ _, err := Load(city)
+ if !errors.Is(err, ErrInsecurePermissions) {
+ t.Fatalf("want ErrInsecurePermissions, got %v", err)
+ }
+}
+
+func TestLoadAcceptsRootOwnedGroupReadable(t *testing.T) {
+ // The accept path end to end, on a real file. Only a root test process can
+ // produce the root:ourgid 0440 shape kubelet mounts, so this is skipped
+ // everywhere else; TestSecureMode covers the predicate unprivileged.
+ if runtime.GOOS == "windows" {
+ t.Skip("permission bits are POSIX-only")
+ }
+ if os.Geteuid() != 0 {
+ t.Skip("needs root to chown the fixture to the Secret-mount shape")
+ }
+ city := t.TempDir()
+ t.Setenv("GC_HOME", t.TempDir())
+ t.Setenv(EnvCredentialsFile, "")
+ t.Setenv("GITHUB_TOKEN", "")
+ t.Setenv("GH_TOKEN", "")
+ t.Setenv(EnvCredentialCommand, "")
+ path := filepath.Join(city, ".gc", "credentials.toml")
+ writeCredFile(t, path, "[[credential]]\nmatch=\"a.com\"\ntoken_file=\"/run/x\"\n", 0o440)
+ if err := os.Chown(path, 0, os.Getegid()); err != nil {
+ t.Fatalf("chown: %v", err)
+ }
+
+ rules, err := Load(city)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if all := rules.All(); len(all) != 1 || all[0].Match != "a.com" {
+ t.Fatalf("want the root-owned rule loaded, got %+v", all)
+ }
+}
+
func TestLoadRejectsLiteralSecretKeys(t *testing.T) {
for _, key := range []string{"token", "password", "secret"} {
t.Run(key, func(t *testing.T) {
diff --git a/internal/gitcred/rules_unix.go b/internal/gitcred/rules_unix.go
new file mode 100644
index 0000000000..0a1420d446
--- /dev/null
+++ b/internal/gitcred/rules_unix.go
@@ -0,0 +1,19 @@
+//go:build !windows
+
+package gitcred
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+// statOwner returns the file's owning uid and gid. ok is false when the
+// FileInfo exposes no Unix ownership metadata; callers must treat that as
+// "owner unknown", never as a match.
+func statOwner(info fs.FileInfo) (uid, gid uint32, ok bool) {
+ stat, isUnix := info.Sys().(*syscall.Stat_t)
+ if !isUnix {
+ return 0, 0, false
+ }
+ return stat.Uid, stat.Gid, true
+}
diff --git a/internal/gitcred/rules_unix_test.go b/internal/gitcred/rules_unix_test.go
new file mode 100644
index 0000000000..ff2f259c4d
--- /dev/null
+++ b/internal/gitcred/rules_unix_test.go
@@ -0,0 +1,35 @@
+//go:build !windows
+
+package gitcred
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestStatOwnerReportsRealOwnership pins the plumbing between os.Stat and
+// secureMode. Every other permission test is a rejection, and a broken
+// statOwner would fail closed and still pass them; only the accept path
+// depends on these values being the real uid/gid, and that path needs root to
+// reproduce (see TestLoadAcceptsRootOwnedGroupReadable).
+func TestStatOwnerReportsRealOwnership(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cred")
+ if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ uid, gid, ok := statOwner(info)
+ if !ok {
+ t.Fatalf("statOwner reported no Unix ownership for %s", path)
+ }
+ if uid != uint32(os.Geteuid()) {
+ t.Fatalf("uid = %d, want %d", uid, os.Geteuid())
+ }
+ if gid != uint32(os.Getegid()) {
+ t.Fatalf("gid = %d, want %d", gid, os.Getegid())
+ }
+}
diff --git a/internal/gitcred/rules_windows.go b/internal/gitcred/rules_windows.go
new file mode 100644
index 0000000000..5de09a75ed
--- /dev/null
+++ b/internal/gitcred/rules_windows.go
@@ -0,0 +1,12 @@
+//go:build windows
+
+package gitcred
+
+import "io/fs"
+
+// statOwner has no Unix ownership to report on Windows. loadFileLayer skips the
+// permission gate there entirely; returning ok=false keeps any other caller
+// fail-closed.
+func statOwner(fs.FileInfo) (uid, gid uint32, ok bool) {
+ return 0, 0, false
+}
diff --git a/internal/graphv2/invocation.go b/internal/graphv2/invocation.go
index 9c4845cdfa..b80dc2a9be 100644
--- a/internal/graphv2/invocation.go
+++ b/internal/graphv2/invocation.go
@@ -164,6 +164,14 @@ func PrepareInvocation(ctx context.Context, store beads.Store, formulaName strin
if len(legacyRefs) > 0 {
memberID, err := ResolveLegacyIssueAlias(store, convoyID)
if err != nil {
+ // NormalizeInputConvoy may have just minted a synthetic input
+ // convoy for targetID; this alias-resolution failure discards the
+ // invocation, so close that freshly-minted artifact before
+ // returning. Leaving it open strands a claim-attracting bead — the
+ // exact leak this guards against — when a cross-store membership
+ // read makes ResolveLegacyIssueAlias fail. A caller-provided convoy
+ // target (convoyID == targetID) is never touched.
+ CloseSyntheticInputConvoy(store, convoyID, targetID)
return Invocation{}, fmt.Errorf("resolving deprecated issue alias for v2 formula %q: %w", formulaName, err)
}
inv.Vars[LegacyIssueVar] = memberID
@@ -171,6 +179,31 @@ func PrepareInvocation(ctx context.Context, store beads.Store, formulaName strin
return inv, nil
}
+// CloseSyntheticInputConvoy best-effort-closes the synthetic input convoy that
+// PrepareInvocation minted for targetID when a later failure discards the
+// invocation, so an aborted pour does not strand an open claim-attracting bead
+// (the accumulating "input convoy for " debris this guards against). It is
+// the single guarded cleanup primitive shared by every graph-v2 pour surface —
+// PrepareInvocation itself, the sling auto-pour path, and the CLI
+// `gc formula cook --attach` path. Only the pour's own artifact is closed: a
+// caller-provided convoy target (convoyID == targetID), an empty id, a bead that
+// is not a synthetic convoy, or an already-terminal convoy is left untouched.
+// The pour's original error is the failure to surface, so close errors are
+// ignored.
+func CloseSyntheticInputConvoy(store beads.Store, convoyID, targetID string) {
+ if store == nil || convoyID == "" || convoyID == targetID {
+ return
+ }
+ b, err := store.Get(convoyID)
+ if err != nil || b.Type != "convoy" || b.Metadata[syntheticMetadataKey] != "true" {
+ return
+ }
+ if convoycore.IsTerminalStatus(b.Status) {
+ return
+ }
+ _ = store.Close(convoyID) //nolint:errcheck // best-effort cleanup of this invocation's own artifact
+}
+
// legacyIssueDeprecations formats deprecation warnings for legacy issue and
// bead_id usages in a graph.v2 formula.
func legacyIssueDeprecations(formulaName string, refs []string) []string {
@@ -402,6 +435,11 @@ func CreateSingleItemInputConvoy(store beads.Store, target beads.Bead) (beads.Be
return beads.Bead{}, fmt.Errorf("creating input convoy for %s: %w", target.ID, err)
}
if err := convoycore.TrackItem(store, created.ID, target.ID); err != nil {
+ // The convoy was minted for this pour and tracks nothing; leaving it
+ // open would strand a synthetic claim-attracting bead every time a
+ // pour fails here (cross-store dep-adds are the observed trigger).
+ // Best-effort close: the tracking error is the failure to surface.
+ _ = store.Close(created.ID) //nolint:errcheck // best-effort cleanup of this pour's own artifact
return beads.Bead{}, fmt.Errorf("tracking %s from input convoy %s: %w", target.ID, created.ID, err)
}
return created, nil
diff --git a/internal/graphv2/invocation_cleanup_test.go b/internal/graphv2/invocation_cleanup_test.go
new file mode 100644
index 0000000000..37be45f843
--- /dev/null
+++ b/internal/graphv2/invocation_cleanup_test.go
@@ -0,0 +1,61 @@
+package graphv2
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beads"
+)
+
+func TestCloseSyntheticInputConvoy(t *testing.T) {
+ newSynthetic := func(t *testing.T, store beads.Store) beads.Bead {
+ t.Helper()
+ c, err := store.Create(beads.Bead{Title: "input convoy for x", Type: "convoy", Metadata: map[string]string{syntheticMetadataKey: "true"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+ }
+ status := func(t *testing.T, store beads.Store, id string) string {
+ t.Helper()
+ b, err := store.Get(id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b.Status
+ }
+
+ t.Run("closes the pour's synthetic convoy", func(t *testing.T) {
+ store := beads.NewMemStore()
+ c := newSynthetic(t, store)
+ CloseSyntheticInputConvoy(store, c.ID, "bd-target")
+ if got := status(t, store, c.ID); got != "closed" {
+ t.Fatalf("synthetic convoy status = %q, want closed", got)
+ }
+ })
+
+ t.Run("never closes a caller-provided convoy target", func(t *testing.T) {
+ store := beads.NewMemStore()
+ c := newSynthetic(t, store)
+ CloseSyntheticInputConvoy(store, c.ID, c.ID)
+ if got := status(t, store, c.ID); got == "closed" {
+ t.Fatal("caller-provided convoy target was closed")
+ }
+ })
+
+ t.Run("leaves non-synthetic convoys untouched", func(t *testing.T) {
+ store := beads.NewMemStore()
+ c, err := store.Create(beads.Bead{Title: "user convoy", Type: "convoy"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ CloseSyntheticInputConvoy(store, c.ID, "bd-target")
+ if got := status(t, store, c.ID); got == "closed" {
+ t.Fatal("non-synthetic convoy was closed")
+ }
+ })
+
+ t.Run("tolerates missing beads and nil store", func(_ *testing.T) {
+ CloseSyntheticInputConvoy(nil, "c-1", "t-1")
+ CloseSyntheticInputConvoy(beads.NewMemStore(), "c-absent", "t-1")
+ })
+}
diff --git a/internal/graphv2/invocation_test.go b/internal/graphv2/invocation_test.go
index 6dee7cca72..ccb93490eb 100644
--- a/internal/graphv2/invocation_test.go
+++ b/internal/graphv2/invocation_test.go
@@ -2,6 +2,7 @@ package graphv2
import (
"context"
+ "fmt"
"maps"
"os"
"os/exec"
@@ -1021,3 +1022,93 @@ func TestRootKeyIgnoresDeprecatedIssueRuntimeVar(t *testing.T) {
t.Fatalf("RootKey with alias vars = %q, want %q (issue/bead_id must not affect idempotence keys)", withAlias, base)
}
}
+
+// depAddFailingStore fails every DepAdd, simulating the cross-store dep-add
+// failure that aborts input-convoy tracking mid-pour.
+type depAddFailingStore struct {
+ beads.Store
+}
+
+func (s depAddFailingStore) DepAdd(fromID, _, _ string) error {
+ return fmt.Errorf("resolving issue ID %s: no issue found matching %q", fromID, fromID)
+}
+
+func TestCreateSingleItemInputConvoyClosesConvoyOnTrackFailure(t *testing.T) {
+ mem := beads.NewMemStore()
+ target, err := mem.Create(beads.Bead{Title: "work item", Type: "task"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := depAddFailingStore{Store: mem}
+
+ _, err = CreateSingleItemInputConvoy(store, target)
+ if err == nil {
+ t.Fatal("CreateSingleItemInputConvoy succeeded, want tracking failure")
+ }
+ // The synthetic convoy minted for this pour must not survive as an open
+ // claim-attracting bead.
+ open, err := mem.List(beads.ListQuery{Type: "convoy"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(open) != 0 {
+ t.Fatalf("open synthetic convoys after failed pour = %d, want 0 (ids: %v)", len(open), open)
+ }
+}
+
+// depListFailingStore mints and tracks convoys normally but fails every
+// DepList, simulating the cross-store membership read anomaly that makes
+// ResolveLegacyIssueAlias fail after PrepareInvocation has already minted the
+// synthetic input convoy.
+type depListFailingStore struct {
+ beads.Store
+}
+
+func (s depListFailingStore) DepList(_, _ string) ([]beads.Dep, error) {
+ return nil, fmt.Errorf("cross-store membership read failed")
+}
+
+func TestPrepareInvocationClosesSyntheticConvoyOnLegacyAliasFailure(t *testing.T) {
+ formulatest.EnableV2ForTest(t)
+ dir := t.TempDir()
+ writeFormula(t, dir, "legacy.formula.toml", `
+formula = "legacy"
+version = 1
+contract = "graph.v2"
+type = "workflow"
+
+[vars]
+[vars.issue]
+description = "legacy work bead"
+required = true
+
+[[steps]]
+id = "inspect"
+title = "Inspect {{issue}}"
+`)
+ mem := beads.NewMemStore()
+ target, err := mem.Create(beads.Bead{Title: "work item", Type: "task"})
+ if err != nil {
+ t.Fatalf("Create target: %v", err)
+ }
+ // DepAdd (convoy tracking) still succeeds, so NormalizeInputConvoy mints the
+ // synthetic convoy; the later DepList inside ResolveLegacyIssueAlias fails.
+ store := depListFailingStore{Store: mem}
+
+ _, err = PrepareInvocation(context.Background(), store, "legacy", []string{dir}, target.ID, nil)
+ if err == nil {
+ t.Fatal("PrepareInvocation succeeded, want legacy alias resolution failure")
+ }
+ if !strings.Contains(err.Error(), "resolving deprecated issue alias") {
+ t.Fatalf("error = %q, want deprecated issue alias failure", err)
+ }
+ // The synthetic convoy minted for the bead target before the alias failure
+ // must not survive as an open claim-attracting bead.
+ open, err := mem.List(beads.ListQuery{Type: "convoy"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(open) != 0 {
+ t.Fatalf("open synthetic convoys after failed pour = %d, want 0 (ids: %v)", len(open), open)
+ }
+}
diff --git a/internal/logutil/walkthrough_urls_test.go b/internal/logutil/walkthrough_urls_test.go
index eea724e382..64e58c3371 100644
--- a/internal/logutil/walkthrough_urls_test.go
+++ b/internal/logutil/walkthrough_urls_test.go
@@ -45,9 +45,16 @@ func TestWalkthroughURLStringsStayInContractFile(t *testing.T) {
case ".git", ".gc", "node_modules":
return filepath.SkipDir
}
- // Skip git worktrees embedded in the repo (have a .git file, not dir).
- if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
- return filepath.SkipDir
+ // Skip git worktrees embedded in the repo (have a .git file, not
+ // dir) — but never apply this to root itself. gc agent sessions run
+ // from inside a worktree, so root legitimately has a .git file
+ // rather than a .git directory; skipping on that condition here
+ // would SkipDir the walk's very first entry and silently visit
+ // zero files.
+ if path != root {
+ if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
+ return filepath.SkipDir
+ }
}
return nil
}
diff --git a/internal/mail/beadmail/beadmail.go b/internal/mail/beadmail/beadmail.go
index 326ec18e03..c4b199c9a1 100644
--- a/internal/mail/beadmail/beadmail.go
+++ b/internal/mail/beadmail/beadmail.go
@@ -342,7 +342,10 @@ type ArchiveFilter struct {
Limit int
}
-// Archive deletes a message bead without reading it.
+// Archive closes a message bead, retaining its body for later retrieval via
+// gc mail peek or bd show. A closed message no longer appears in inbox views
+// (all listing paths filter Status != "open"). Archiving an already-closed
+// message is idempotent and returns ErrAlreadyArchived without mutating it.
func (p *Provider) Archive(id string) error {
b, err := p.store.Get(id)
if err != nil {
@@ -355,15 +358,9 @@ func (p *Provider) Archive(id string) error {
return fmt.Errorf("beadmail archive: bead %s is not a message", id)
}
if b.Status == "closed" {
- if err := p.store.Delete(id); err != nil {
- if errors.Is(err, beads.ErrNotFound) {
- return mail.ErrAlreadyArchived
- }
- return fmt.Errorf("beadmail archive: %w", err)
- }
return mail.ErrAlreadyArchived
}
- if err := p.store.Delete(id); err != nil {
+ if err := p.store.Close(id); err != nil {
if errors.Is(err, beads.ErrNotFound) {
return mail.ErrAlreadyArchived
}
@@ -412,8 +409,9 @@ func (p *Provider) ArchiveCandidates(filter ArchiveFilter) ([]mail.Message, erro
return matches, nil
}
-// ArchiveMatching deletes open messages selected by filter without per-message
-// lookups after the candidate list has already verified them.
+// ArchiveMatching archives open messages selected by filter without per-message
+// lookups after the candidate list has already verified them. Matched beads are
+// closed rather than deleted, so their bodies stay readable.
func (p *Provider) ArchiveMatching(filter ArchiveFilter) ([]mail.Message, []mail.ArchiveResult, error) {
candidates, err := p.ArchiveCandidates(filter)
if err != nil {
@@ -429,7 +427,7 @@ func (p *Provider) ArchiveMatching(filter ArchiveFilter) ([]mail.Message, []mail
return candidates, results, nil
}
for i, id := range ids {
- if err := p.store.Delete(id); err != nil {
+ if err := p.store.Close(id); err != nil {
if errors.Is(err, beads.ErrNotFound) {
results[i].Err = mail.ErrAlreadyArchived
continue
@@ -510,7 +508,7 @@ func (p *Provider) Delete(id string) error {
return p.Archive(id)
}
-// ArchiveMany archives a batch of messages by deleting each bead eagerly,
+// ArchiveMany archives a batch of messages by closing each bead eagerly,
// preserving per-id error reporting that matches [Provider.Archive].
func (p *Provider) ArchiveMany(ids []string) ([]mail.ArchiveResult, error) {
if len(ids) == 0 {
diff --git a/internal/mail/beadmail/beadmail_test.go b/internal/mail/beadmail/beadmail_test.go
index 9eca872c5c..58607fad79 100644
--- a/internal/mail/beadmail/beadmail_test.go
+++ b/internal/mail/beadmail/beadmail_test.go
@@ -1002,8 +1002,15 @@ func TestArchive(t *testing.T) {
t.Fatalf("Archive: %v", err)
}
- if _, err := store.Get(sent.ID); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("store.Get(%s) err = %v, want ErrNotFound", sent.ID, err)
+ b, err := store.Get(sent.ID)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after Archive: %v (want bead retained)", sent.ID, err)
+ }
+ if b.Status != "closed" {
+ t.Errorf("bead status = %q, want \"closed\"", b.Status)
+ }
+ if b.Description != "dismiss me" {
+ t.Errorf("bead body = %q, want \"dismiss me\"", b.Description)
}
}
@@ -1089,12 +1096,23 @@ func TestLegacyClosedMessageBeadTreatedAsRemoved(t *testing.T) {
}
}
- // Archive must still delete a closed legacy message when called explicitly.
+ // Archiving an already-closed legacy message is idempotent (ErrAlreadyArchived)
+ // and must NOT destroy the store row: #4422 forbids store.Delete on any archive
+ // path, including legacy cleanup. The bead stays retained and recoverable via
+ // bd show / store.Get, while remaining removed from every mail view (asserted
+ // above). View-removal (#4350) and store-retention (#4422) are orthogonal.
if err := p.Archive(legacy.ID); !errors.Is(err, mail.ErrAlreadyArchived) {
t.Errorf("Archive(legacy closed) error = %v, want ErrAlreadyArchived", err)
}
- if _, err := store.Get(legacy.ID); !errors.Is(err, beads.ErrNotFound) {
- t.Errorf("store.Get(legacy) after Archive err = %v, want ErrNotFound", err)
+ retained, err := store.Get(legacy.ID)
+ if err != nil {
+ t.Fatalf("store.Get(legacy) after Archive: %v (want bead retained, not deleted)", err)
+ }
+ if retained.Status != "closed" {
+ t.Errorf("legacy bead status after Archive = %q, want \"closed\"", retained.Status)
+ }
+ if retained.Description != "closed by an old release" {
+ t.Errorf("legacy bead body after Archive = %q, want retained", retained.Description)
}
}
@@ -1164,13 +1182,18 @@ func TestArchiveAlreadyClosed(t *testing.T) {
}
store.Close(sent.ID) //nolint:errcheck
- // Archiving already-closed message returns ErrAlreadyArchived.
+ // Archiving an already-closed message returns ErrAlreadyArchived without
+ // deleting the bead (idempotent, body retained).
err = p.Archive(sent.ID)
if !errors.Is(err, mail.ErrAlreadyArchived) {
t.Errorf("Archive already closed: got %v, want ErrAlreadyArchived", err)
}
- if _, err := store.Get(sent.ID); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("store.Get(%s) err = %v, want ErrNotFound", sent.ID, err)
+ b, getErr := store.Get(sent.ID)
+ if getErr != nil {
+ t.Fatalf("store.Get(%s) after Archive of closed bead: %v (want bead retained)", sent.ID, getErr)
+ }
+ if b.Status != "closed" {
+ t.Errorf("bead status = %q, want \"closed\"", b.Status)
}
}
@@ -1202,7 +1225,7 @@ func TestArchiveNotFound(t *testing.T) {
}
}
-func TestArchiveReadAfterDeleteReturnsNotFound(t *testing.T) {
+func TestArchiveRetainsBodyReadableAfterClose(t *testing.T) {
store := beads.NewMemStore()
p := New(store)
@@ -1214,12 +1237,28 @@ func TestArchiveReadAfterDeleteReturnsNotFound(t *testing.T) {
t.Fatalf("Archive: %v", err)
}
+ // #4422 guarantees the row is RETAINED at the store, not destroyed — the fix
+ // is that Archive closes instead of store.Delete. Recovery is via bd show /
+ // store.Get, NOT the mail API: p.Get correctly hides an archived message per
+ // #4350's view contract (isRemovedMessageBead). Assert the durability claim at
+ // the layer that actually carries it.
+ b, err := store.Get(sent.ID)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after Archive: %v (want body retained)", sent.ID, err)
+ }
+ if b.Status != "closed" {
+ t.Errorf("archived bead status = %q, want \"closed\"", b.Status)
+ }
+ if b.Description != "dismiss me" {
+ t.Errorf("archived bead body = %q, want \"dismiss me\"", b.Description)
+ }
+ // And it stays hidden from the mail API, like every archived message.
if _, err := p.Get(sent.ID); !errors.Is(err, mail.ErrNotFound) {
- t.Fatalf("Get(%s) err = %v, want ErrNotFound", sent.ID, err)
+ t.Errorf("p.Get after Archive err = %v, want ErrNotFound (hidden from mail views)", err)
}
}
-func TestArchiveManyDeletesImmediately(t *testing.T) {
+func TestArchiveManyClosesAndRetains(t *testing.T) {
store := beads.NewMemStore()
p := New(store)
@@ -1242,8 +1281,12 @@ func TestArchiveManyDeletesImmediately(t *testing.T) {
}
}
for _, id := range []string{a.ID, b.ID} {
- if _, err := store.Get(id); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("store.Get(%s) err = %v, want ErrNotFound", id, err)
+ bead, err := store.Get(id)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after ArchiveMany: %v (want bead retained)", id, err)
+ }
+ if bead.Status != "closed" {
+ t.Errorf("bead %s status = %q, want \"closed\"", id, bead.Status)
}
}
}
@@ -1282,8 +1325,12 @@ func TestArchiveManyReportsPerIDResults(t *testing.T) {
t.Errorf("results[2].Err = %v, want nil", results[2].Err)
}
for _, id := range []string{a.ID, b.ID} {
- if _, err := store.Get(id); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("store.Get(%s) err = %v, want ErrNotFound", id, err)
+ bead, err := store.Get(id)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after ArchiveMany: %v (want bead retained)", id, err)
+ }
+ if bead.Status != "closed" {
+ t.Errorf("bead %s status = %q, want \"closed\"", id, bead.Status)
}
}
if _, err := store.Get(task.ID); err != nil {
@@ -1291,6 +1338,38 @@ func TestArchiveManyReportsPerIDResults(t *testing.T) {
}
}
+// TestArchiveDoubleArchiveRetainsBody guards the edge case where the same
+// message is archived twice: the second call must NOT delete the bead (which
+// is now "closed" after the first call hits the closed-branch and returns
+// ErrAlreadyArchived without mutating it).
+func TestArchiveDoubleArchiveRetainsBody(t *testing.T) {
+ store := beads.NewMemStore()
+ p := New(store)
+
+ sent, err := p.Send("human", "mayor", "", "archive twice")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := p.Archive(sent.ID); err != nil {
+ t.Fatalf("first Archive: %v", err)
+ }
+ if err := p.Archive(sent.ID); !errors.Is(err, mail.ErrAlreadyArchived) {
+ t.Fatalf("second Archive: err = %v, want ErrAlreadyArchived", err)
+ }
+
+ b, err := store.Get(sent.ID)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after double Archive: %v (want bead retained)", sent.ID, err)
+ }
+ if b.Status != "closed" {
+ t.Errorf("bead status after double Archive = %q, want \"closed\"", b.Status)
+ }
+ if b.Description != "archive twice" {
+ t.Errorf("bead body after double Archive = %q, want \"archive twice\"", b.Description)
+ }
+}
+
func TestArchiveManyDoesNotUseCloseAll(t *testing.T) {
store := noCloseAllStore{MemStore: beads.NewMemStore(), t: t}
p := New(store)
@@ -1350,9 +1429,18 @@ func TestArchiveMatchingSkipsPerMessageGet(t *testing.T) {
t.Fatalf("results[%d].Err = %v", i, r.Err)
}
}
+ // Retention contract: matched messages are closed, not destroyed, so the
+ // bead stays retrievable and its body stays readable (see #4422).
for _, id := range []string{matchingA.ID, matchingB.ID} {
- if _, err := base.Get(id); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("Get(%s) err = %v, want ErrNotFound", id, err)
+ got, err := base.Get(id)
+ if err != nil {
+ t.Fatalf("Get(%s) after archive: %v, want the bead retained", id, err)
+ }
+ if got.Status != "closed" {
+ t.Fatalf("archived message %s status = %q, want closed", id, got.Status)
+ }
+ if got.Description == "" {
+ t.Fatalf("archived message %s lost its body, want it retained", id)
}
}
got, err := base.Get(other.ID)
@@ -1390,8 +1478,12 @@ func TestDelete(t *testing.T) {
t.Fatalf("Delete: %v", err)
}
- if _, err := store.Get(sent.ID); !errors.Is(err, beads.ErrNotFound) {
- t.Fatalf("store.Get(%s) err = %v, want ErrNotFound", sent.ID, err)
+ b, err := store.Get(sent.ID)
+ if err != nil {
+ t.Fatalf("store.Get(%s) after Delete: %v (want bead retained)", sent.ID, err)
+ }
+ if b.Status != "closed" {
+ t.Errorf("bead status = %q, want \"closed\"", b.Status)
}
}
diff --git a/internal/materialize/skills.go b/internal/materialize/skills.go
index d97268296a..76e4d7ef37 100644
--- a/internal/materialize/skills.go
+++ b/internal/materialize/skills.go
@@ -46,20 +46,34 @@ import (
"strings"
"github.com/gastownhall/gascity/internal/bootstrap"
+ "github.com/gastownhall/gascity/internal/citylayout"
"github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/fsys"
+ "github.com/gastownhall/gascity/internal/pathutil"
)
// vendorSinks maps an agent provider to the relative directory under the
// agent's scope-root or session WorkDir where skills are materialized.
//
-// Only the providers with verified skill-reading behavior are included.
+// Each path is the project-scoped skills directory that the provider's own
+// CLI actually scans (a directory of /SKILL.md), verified against
+// vendor docs (2026-06):
+//
+// claude → .claude/skills (code.claude.com/docs/en/skills)
+// codex → .agents/skills (developers.openai.com/codex/skills — Codex
+// scans .agents/skills from cwd up to the repo
+// root; it does NOT read a project-scoped
+// .codex/skills, only ~/.codex for user state)
+// gemini → .gemini/skills (github.com/google-gemini/gemini-cli docs/cli/skills.md)
+// opencode → .opencode/skills (opencode.ai/docs/skills)
+// mimocode → .mimocode/skills (mimo.xiaomi.com/mimocode/skills)
+//
// The other providers recognized by hooks.go (copilot, cursor, pi, omp)
// intentionally have no entry — VendorSink returns ok=false so the caller
// can log a single skip line per session.
var vendorSinks = map[string]string{
"claude": ".claude/skills",
- "codex": ".codex/skills",
+ "codex": ".agents/skills",
"gemini": ".gemini/skills",
"opencode": ".opencode/skills",
"mimocode": ".mimocode/skills",
@@ -327,6 +341,19 @@ type Request struct {
// symlinks. Pass nil to skip legacy migration. Use LegacyStubNames()
// for the canonical list.
LegacyNames []string
+ // LegacyOwnedRoots lists RETIRED gc-managed source roots whose
+ // stranded symlinks the cleanup walk should still recognize as
+ // gc-owned: targets under them are gc's own leftover property, never
+ // user content. The motivating case is the .gc/system/packs
+ // projection retired by #3344 with a config-only migration —
+ // pre-manifest sink links pointing into it classify as "user-owned"
+ // under OwnedRoots+manifest alone and are skipped forever
+ // (hq-38je). Links under these roots are re-pointed when their name
+ // is desired and deleted only once dangling when undesired; a
+ // still-resolving legacy link for an undesired name is left alone.
+ // Use LegacyOwnedRootsFor for the canonical list. Pass nil to keep
+ // the historical behavior.
+ LegacyOwnedRoots []string
}
// SkippedConflict records a name in the desired set that could not be
@@ -478,6 +505,19 @@ func Run(req Request) (Result, error) {
manifest := loadOwnershipManifest(absSink)
manifestDirty := false
+ legacyOwned := make([]string, 0, len(req.LegacyOwnedRoots))
+ for _, root := range req.LegacyOwnedRoots {
+ if root == "" {
+ continue
+ }
+ canon, err := canonicalizePath(root)
+ if err != nil {
+ result.Warnings = append(result.Warnings, fmt.Sprintf("canonicalize legacy owned root %q: %v", root, err))
+ continue
+ }
+ legacyOwned = append(legacyOwned, canon)
+ }
+
// Step 2: legacy stub migration.
for _, name := range req.LegacyNames {
path := filepath.Join(absSink, name)
@@ -524,15 +564,30 @@ func Run(req Request) (Result, error) {
result.Warnings = append(result.Warnings, fmt.Sprintf("canonicalize target %q: %v", target, terr))
continue
}
+ legacyTarget := false
if !targetUnderOwnedRoot(canonTarget, owned) && !manifestRecordsTarget(manifest, name, canonTarget) {
- // External target — symlink the user placed themselves. Not
- // under any currently-owned root, and not a target this
+ // Not under any currently-owned root, and not a target this
// materializer's own manifest remembers writing for this name
- // in a previous pass.
- continue
+ // in a previous pass. Retired gc-managed roots
+ // (LegacyOwnedRoots) still mark the link as gc's own stranded
+ // property — e.g. a pre-#3344 .gc/system/packs projection
+ // target orphaned by the config-only retirement migration.
+ if !targetUnderOwnedRoot(canonTarget, legacyOwned) {
+ // External target — symlink the user placed themselves.
+ continue
+ }
+ legacyTarget = true
}
desired, want := desiredByName[name]
if !want {
+ if legacyTarget {
+ // Stranded legacy-root links are removed only once they
+ // dangle; a still-resolving link for an undesired name may
+ // be serving content the user relies on.
+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
+ continue
+ }
+ }
// Owned but not desired — delete (covers dangling and orphaned).
if rmErr := os.Remove(path); rmErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("removing orphan symlink %q: %v", path, rmErr))
@@ -641,6 +696,52 @@ func Run(req Request) (Result, error) {
return result, nil
}
+// TargetUnderManagedRoot reports whether target falls under one of the
+// given gc-managed roots, canonicalizing both sides the same way the
+// cleanup walk does so /var ↔ /private/var aliases compare equal. It
+// exists so the doctor dangling-sink check classifies link ownership
+// with exactly the materializer's logic instead of drifting into a
+// second convention. Canonicalization failures classify as false (not
+// owned) — the safe direction for a deletion decision.
+func TargetUnderManagedRoot(target string, roots []string) bool {
+ canonTarget, err := canonicalizePath(target)
+ if err != nil {
+ return false
+ }
+ canonRoots := make([]string, 0, len(roots))
+ for _, root := range roots {
+ if root == "" {
+ continue
+ }
+ canon, err := canonicalizePath(root)
+ if err != nil {
+ continue
+ }
+ canonRoots = append(canonRoots, canon)
+ }
+ return targetUnderOwnedRoot(canonTarget, canonRoots)
+}
+
+// LegacyOwnedRootsFor returns the canonical retired gc-managed source
+// roots for Request.LegacyOwnedRoots:
+//
+// - /.gc/system/packs — the per-city projection retired by
+// #3344, whose config-only migration stranded every pre-manifest
+// sink symlink that pointed into it (hq-38je).
+// - /cache/repos — the global content-addressed pack
+// checkout cache; a pruned checkout strands pre-manifest links
+// that #4130's manifest only covers going forward.
+//
+// The cache root is omitted when GC_HOME is unresolvable (hermetic
+// test binaries) rather than erroring the whole materialization pass.
+func LegacyOwnedRootsFor(cityPath string) []string {
+ roots := []string{filepath.Join(cityPath, citylayout.SystemPacksRoot)}
+ if cacheRoot, err := config.GlobalRepoCacheRoot(); err == nil {
+ roots = append(roots, cacheRoot)
+ }
+ return roots
+}
+
// LegacyStubNames returns the canonical list of v0.15.0 stub names that
// the materializer migrates on the first post-upgrade pass. These are
// the gc- stubs the old materializeSkillStubs wrote into every
@@ -834,52 +935,19 @@ func targetUnderOwnedRoot(target string, ownedRoots []string) bool {
return false
}
-// canonicalizePath returns a path with all leading symlinks resolved
-// (via filepath.EvalSymlinks). When the path itself does not exist
-// (e.g., a dangling symlink target or a not-yet-created sink entry),
-// the function walks up to find the deepest ancestor that does exist,
-// canonicalizes that, and re-appends the missing tail. This handles
-// platforms where common roots are symlinks (macOS /tmp →
-// /private/tmp; certain Linux distros where /var symlinks elsewhere)
-// without breaking comparisons against materializer-written targets
-// that may have been recorded with the unresolved prefix.
+// canonicalizePath returns path with all symlinks resolved, walking up to
+// the deepest existing ancestor when path itself does not exist (e.g., a
+// dangling symlink target or a not-yet-created sink entry) and re-appending
+// the missing tail. Delegates to pathutil.NormalizePathForCompare, which
+// also collapses platform path aliases (macOS /tmp → /private/tmp; certain
+// Linux distros where /var symlinks elsewhere) so comparisons against
+// materializer-written targets don't break on an unresolved prefix.
//
-// Returns an error only when filepath.Abs fails on a relative input.
-// All EvalSymlinks errors are absorbed by the walk-up fallback.
-func canonicalizePath(path string) (string, error) {
- if path == "" {
- return "", nil
- }
- abs := path
- if !filepath.IsAbs(abs) {
- a, err := filepath.Abs(abs)
- if err != nil {
- return "", err
- }
- abs = a
- }
- abs = filepath.Clean(abs)
- if resolved, err := filepath.EvalSymlinks(abs); err == nil {
- return resolved, nil
- }
- // Walk up until an ancestor exists; canonicalize it, then re-append
- // the missing suffix. Falls back to the cleaned absolute path when
- // nothing along the way exists (e.g., entirely-fictional path
- // supplied by a test).
- var suffix []string
- cur := abs
- for {
- parent := filepath.Dir(cur)
- suffix = append([]string{filepath.Base(cur)}, suffix...)
- if parent == cur {
- return abs, nil
- }
- if resolved, err := filepath.EvalSymlinks(parent); err == nil {
- parts := append([]string{resolved}, suffix...)
- return filepath.Join(parts...), nil
- }
- cur = parent
- }
+// Always returns a nil error; the signature is kept for call-site
+// compatibility (all callers already treat resolution failure as
+// non-fatal).
+func canonicalizePath(path string) (string, error) { //nolint:unparam // error slot preserves the call-site contract for all 7 callers
+ return pathutil.NormalizePathForCompare(path), nil
}
// atomicSymlink creates or replaces a symlink at path pointing to
diff --git a/internal/materialize/skills_test.go b/internal/materialize/skills_test.go
index 8da1df624e..bcf69e234d 100644
--- a/internal/materialize/skills_test.go
+++ b/internal/materialize/skills_test.go
@@ -11,6 +11,8 @@ import (
"strings"
"testing"
+ "github.com/gastownhall/gascity/internal/testutil"
+
"github.com/gastownhall/gascity/internal/bootstrap"
"github.com/gastownhall/gascity/internal/config"
)
@@ -99,7 +101,7 @@ func TestVendorSink(t *testing.T) {
wantOK bool
}{
{"claude", ".claude/skills", true},
- {"codex", ".codex/skills", true},
+ {"codex", ".agents/skills", true},
{"gemini", ".gemini/skills", true},
{"opencode", ".opencode/skills", true},
{"mimocode", ".mimocode/skills", true},
@@ -771,6 +773,162 @@ func TestMaterializeAgentSinkDirRequired(t *testing.T) {
}
}
+// legacyRootTarget builds a symlink at sink/ pointing into a
+// retired root (e.g. the pre-#3344 .gc/system/packs projection) that no
+// longer exists on disk — the orphaned shape hq-38je root-caused.
+func mustDanglingLegacyLink(t *testing.T, sink, name, legacyRoot string) string {
+ t.Helper()
+ target := filepath.Join(legacyRoot, "core", "skills", name)
+ mustSymlink(t, target, filepath.Join(sink, name))
+ return target
+}
+
+func TestRunDeletesDanglingLegacyRootLink(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ mkSkill(t, src, "gc-work")
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs") // never created — retired
+ mustDanglingLegacyLink(t, sink, "qlandia-crew.prep-convoy", legacyRoot)
+
+ _, err := Run(Request{
+ SinkDir: sink,
+ Desired: []SkillEntry{{Name: "gc-work", Source: filepath.Join(src, "gc-work"), Origin: "core"}},
+ OwnedRoots: []string{src},
+ LegacyOwnedRoots: []string{legacyRoot},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(filepath.Join(sink, "qlandia-crew.prep-convoy")); !os.IsNotExist(err) {
+ t.Errorf("dangling legacy link survived: lstat err=%v", err)
+ }
+ checkSymlink(t, filepath.Join(sink, "gc-work"), filepath.Join(src, "gc-work"))
+}
+
+func TestRunRepointsDesiredLegacyRootLink(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ mkSkill(t, src, "gc-mail")
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ mustDanglingLegacyLink(t, sink, "gc-mail", legacyRoot)
+
+ res, err := Run(Request{
+ SinkDir: sink,
+ Desired: []SkillEntry{{Name: "gc-mail", Source: filepath.Join(src, "gc-mail"), Origin: "core"}},
+ OwnedRoots: []string{src},
+ LegacyOwnedRoots: []string{legacyRoot},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(res.Materialized, []string{"gc-mail"}) {
+ t.Fatalf("Materialized = %v", res.Materialized)
+ }
+ checkSymlink(t, filepath.Join(sink, "gc-mail"), filepath.Join(src, "gc-mail"))
+ if len(res.Skipped) != 0 {
+ t.Errorf("legacy-target link misreported as user-owned: %+v", res.Skipped)
+ }
+}
+
+func TestRunRepointsLiveDesiredLegacyRootLink(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ mkSkill(t, src, "gc-mail")
+ sink := t.TempDir()
+ // Legacy target still exists on disk (e.g. an old cache checkout not
+ // yet pruned): a desired name must still re-point at the current
+ // source — the pre-manifest #4130 case.
+ legacyRoot := t.TempDir()
+ legacyTarget := filepath.Join(legacyRoot, "oldsha", "skills", "gc-mail")
+ if err := os.MkdirAll(legacyTarget, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ mustSymlink(t, legacyTarget, filepath.Join(sink, "gc-mail"))
+
+ res, err := Run(Request{
+ SinkDir: sink,
+ Desired: []SkillEntry{{Name: "gc-mail", Source: filepath.Join(src, "gc-mail"), Origin: "core"}},
+ OwnedRoots: []string{src},
+ LegacyOwnedRoots: []string{legacyRoot},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(res.Materialized, []string{"gc-mail"}) {
+ t.Fatalf("Materialized = %v", res.Materialized)
+ }
+ checkSymlink(t, filepath.Join(sink, "gc-mail"), filepath.Join(src, "gc-mail"))
+}
+
+func TestRunKeepsLiveUndesiredLegacyRootLink(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ sink := t.TempDir()
+ // Live legacy target + name not desired: leave alone. Deleting a
+ // still-resolving link could strand content the user relies on; the
+ // doctor check surfaces it instead.
+ legacyRoot := t.TempDir()
+ legacyTarget := filepath.Join(legacyRoot, "core", "skills", "old-skill")
+ if err := os.MkdirAll(legacyTarget, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ mustSymlink(t, legacyTarget, filepath.Join(sink, "old-skill"))
+
+ _, err := Run(Request{
+ SinkDir: sink,
+ Desired: nil,
+ OwnedRoots: []string{src},
+ LegacyOwnedRoots: []string{legacyRoot},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkSymlink(t, filepath.Join(sink, "old-skill"), legacyTarget)
+}
+
+func TestRunKeepsDanglingLegacyRootLinkWithoutOptIn(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ sink := t.TempDir()
+ legacyRoot := filepath.Join(t.TempDir(), ".gc", "system", "packs")
+ target := mustDanglingLegacyLink(t, sink, "core.gc-mail", legacyRoot)
+
+ // No LegacyOwnedRoots: the historical behavior — orphaned pre-manifest
+ // links classify as user-owned and survive forever.
+ _, err := Run(Request{
+ SinkDir: sink,
+ OwnedRoots: []string{src},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkSymlink(t, filepath.Join(sink, "core.gc-mail"), target)
+}
+
+func TestRunDeletesDanglingCacheRepoLink(t *testing.T) {
+ t.Parallel()
+ src := t.TempDir()
+ sink := t.TempDir()
+ // Cache checkout pruned out from under a pre-manifest link.
+ cacheRoot := filepath.Join(t.TempDir(), ".gc", "cache", "repos")
+ target := filepath.Join(cacheRoot, "be555e483c79", "internal", "bootstrap", "packs", "core", "skills", "gc-mail")
+ mustSymlink(t, target, filepath.Join(sink, "core.gc-mail"))
+
+ _, err := Run(Request{
+ SinkDir: sink,
+ OwnedRoots: []string{src},
+ LegacyOwnedRoots: []string{cacheRoot},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(filepath.Join(sink, "core.gc-mail")); !os.IsNotExist(err) {
+ t.Errorf("dangling cache-target link survived: lstat err=%v", err)
+ }
+}
+
func TestMaterializeAgentRemovesAllOwnedWhenDesiredEmpty(t *testing.T) {
t.Parallel()
src := t.TempDir()
@@ -1035,9 +1193,11 @@ func TestCanonicalizePath(t *testing.T) {
t.Fatal(err)
}
expected, _ := filepath.EvalSymlinks(alias)
- if got != expected {
- t.Errorf("alias dir: got %q, want %q", got, expected)
- }
+ // testutil.AssertSamePath, not ==: expectations come from bare
+ // filepath.EvalSymlinks while canonicalizePath normalizes through pathutil,
+ // which on darwin collapses /private/var and /private/tmp back to /var and
+ // /tmp — the reverse direction. Same file, two spellings (macOS only).
+ testutil.AssertCanonicalPathEquals(t, got, expected)
// Missing tail under an aliased ancestor: walk-up + suffix re-append.
missing := filepath.Join(alias, "not-yet-created", "leaf")
@@ -1047,9 +1207,7 @@ func TestCanonicalizePath(t *testing.T) {
}
wantPrefix, _ := filepath.EvalSymlinks(alias)
wantMissing := filepath.Join(wantPrefix, "not-yet-created", "leaf")
- if got != wantMissing {
- t.Errorf("missing tail: got %q, want %q", got, wantMissing)
- }
+ testutil.AssertCanonicalPathEquals(t, got, wantMissing)
// Empty input.
if got, err := canonicalizePath(""); err != nil || got != "" {
diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go
index 3602bf86a9..10ac6e0b5c 100644
--- a/internal/migrate/migrate.go
+++ b/internal/migrate/migrate.go
@@ -90,6 +90,7 @@ type agentFile struct {
MaxSessionAge string `toml:"max_session_age,omitempty"`
MaxSessionAgeJitter string `toml:"max_session_age_jitter,omitempty"`
SleepAfterIdle string `toml:"sleep_after_idle,omitempty"`
+ AssignedWorkDeferLimit *int `toml:"assigned_work_defer_limit,omitempty"`
InstallAgentHooks []string `toml:"install_agent_hooks,omitempty"`
HooksInstalled *bool `toml:"hooks_installed,omitempty"`
InjectAssignedSkills *bool `toml:"inject_assigned_skills,omitempty"`
@@ -945,6 +946,7 @@ func agentConfigFromAgent(agent config.Agent) agentFile {
MaxSessionAge: agent.MaxSessionAge,
MaxSessionAgeJitter: agent.MaxSessionAgeJitter,
SleepAfterIdle: agent.SleepAfterIdle,
+ AssignedWorkDeferLimit: agent.AssignedWorkDeferLimit,
InstallAgentHooks: agent.InstallAgentHooks,
HooksInstalled: agent.HooksInstalled,
InjectAssignedSkills: agent.InjectAssignedSkills,
@@ -997,6 +999,7 @@ func isZeroAgentConfig(cfg agentFile) bool {
cfg.MaxSessionAge == "" &&
cfg.MaxSessionAgeJitter == "" &&
cfg.SleepAfterIdle == "" &&
+ cfg.AssignedWorkDeferLimit == nil &&
len(cfg.InstallAgentHooks) == 0 &&
cfg.HooksInstalled == nil &&
cfg.InjectAssignedSkills == nil &&
diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go
index 6cfeb48b54..057d764695 100644
--- a/internal/migrate/migrate_test.go
+++ b/internal/migrate/migrate_test.go
@@ -1142,6 +1142,7 @@ func TestAgentConfigFromAgentCoversPersistedFields(t *testing.T) {
MaxSessionAge: "5h",
MaxSessionAgeJitter: "15m",
SleepAfterIdle: "30s",
+ AssignedWorkDeferLimit: intPtr(4),
InstallAgentHooks: []string{"claude"},
HooksInstalled: &trueVal,
InjectAssignedSkills: &trueVal,
diff --git a/internal/modelwindow/modelwindow.go b/internal/modelwindow/modelwindow.go
new file mode 100644
index 0000000000..0701c275b4
--- /dev/null
+++ b/internal/modelwindow/modelwindow.go
@@ -0,0 +1,65 @@
+// Package modelwindow resolves an LLM model ID to its context-window size in
+// tokens. It is the single source of truth shared by the session-log context
+// reader (internal/sessionlog) and the CLI context-pressure injector
+// (cmd/gc/context_inject.go) so the two cannot resolve the same model ID to
+// different windows.
+package modelwindow
+
+import "strings"
+
+const (
+ // Million is the context window, in tokens, for 1M-token model variants.
+ Million = 1_000_000
+ // Default is the conservative fallback window for a recognized Claude
+ // family that is not a 1M variant (e.g. Haiku, Opus 4.5 and earlier).
+ Default = 200_000
+)
+
+// millionMarkers force a 1M window when any is a substring of the model ID.
+// Verified against the /v1/models reference (max_input_tokens); opus-4-5,
+// opus-4-1 and haiku-4-5 are 200K and deliberately absent.
+var millionMarkers = []string{
+ "[1m]", "fable", "mythos",
+ "opus-4-6", "opus-4-7", "opus-4-8", "opus-5",
+ "sonnet-4-6", "sonnet-5",
+}
+
+// familyWindows pairs a model-family keyword with its context-window size, in
+// longest-match-first order so a longer keyword wins over a shorter one it
+// contains (e.g. "gpt-4o" before "gpt-4"). Claude families resolve to Default
+// here; their 1M variants are caught earlier by millionMarkers.
+var familyWindows = []struct {
+ keyword string
+ window int
+}{
+ {"gpt-4o", 128_000},
+ {"gpt-5", 258_000},
+ {"gpt-4", 128_000},
+ {"opus", Default},
+ {"sonnet", Default},
+ {"haiku", Default},
+ {"gemini", Million},
+ {"codex", 258_000},
+}
+
+// Window returns the context-window size, in tokens, for a model ID. Claude
+// variants (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5, Fable, Mythos) and any model
+// carrying the explicit "[1m]" launch suffix resolve to the 1M window; older or
+// unrecognized Claude variants use the 200K Default. Returns 0 when the model
+// family is unrecognized, so callers can apply their own unknown-model policy
+// (the session-log/API path treats 0 as "window unknown"; the injector floors
+// it to Default).
+func Window(model string) int {
+ lower := strings.ToLower(model)
+ for _, marker := range millionMarkers {
+ if strings.Contains(lower, marker) {
+ return Million
+ }
+ }
+ for _, f := range familyWindows {
+ if strings.Contains(lower, f.keyword) {
+ return f.window
+ }
+ }
+ return 0
+}
diff --git a/internal/modelwindow/modelwindow_test.go b/internal/modelwindow/modelwindow_test.go
new file mode 100644
index 0000000000..a9aa204fad
--- /dev/null
+++ b/internal/modelwindow/modelwindow_test.go
@@ -0,0 +1,53 @@
+package modelwindow
+
+import "testing"
+
+func TestWindow(t *testing.T) {
+ tests := []struct {
+ model string
+ want int
+ }{
+ // Modern Claude variants resolve to 1M WITHOUT the "[1m]" suffix — 1M is
+ // their plain default, and the provider echoes the model ID back without
+ // the launch flag, so a session log only ever carries the bare form.
+ {"claude-opus-4-8", Million},
+ {"claude-opus-4-7", Million},
+ {"claude-opus-4-6", Million},
+ {"claude-opus-5", Million},
+ {"claude-sonnet-4-6", Million},
+ {"claude-sonnet-5", Million},
+ {"claude-sonnet-5-20260101", Million}, // dated variant still matches
+ {"claude-opus-4-8-20260101", Million}, // dated variant still matches
+ {"claude-opus-5-20260724", Million}, // dated variant still matches
+ {"CLAUDE-OPUS-5", Million}, // case-insensitive
+ {"claude-fable-5", Million},
+ {"claude-mythos-1", Million},
+ // The explicit "[1m]" suffix forces 1M for any Claude family, including
+ // ones whose bare form is 200K.
+ {"claude-opus-4-8[1m]", Million},
+ {"sonnet[1m]", Million},
+ {"claude-haiku-4-5-20251001[1m]", Million},
+ // Older Claude families stay at the conservative default. The opus-5
+ // marker must not swallow opus-4-5/opus-4-1 by substring.
+ {"claude-opus-4-5-20251101", Default},
+ {"claude-opus-4-1-20250805", Default},
+ {"claude-sonnet-4-5-20250929", Default},
+ {"claude-haiku-4-5-20251001", Default},
+ // Non-Claude families.
+ {"gemini-2.5-pro", Million},
+ {"gpt-5-20260101", 258_000},
+ {"codex-mini-latest", 258_000},
+ {"gpt-4o-2024-08-06", 128_000},
+ {"gpt-4-turbo", 128_000},
+ // Unrecognized families return 0 so callers apply their own policy.
+ {"unknown-model-xyz", 0},
+ {"", 0},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ if got := Window(tt.model); got != tt.want {
+ t.Errorf("Window(%q) = %d, want %d", tt.model, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/modelwindow/testenv_import_test.go b/internal/modelwindow/testenv_import_test.go
new file mode 100644
index 0000000000..a6303000e5
--- /dev/null
+++ b/internal/modelwindow/testenv_import_test.go
@@ -0,0 +1,5 @@
+// Code generated by go run scripts/add-testenv-import.go; DO NOT EDIT.
+
+package modelwindow
+
+import _ "github.com/gastownhall/gascity/internal/testenv"
diff --git a/internal/molecule/graph_apply.go b/internal/molecule/graph_apply.go
index 1618a57fe9..baaeadb045 100644
--- a/internal/molecule/graph_apply.go
+++ b/internal/molecule/graph_apply.go
@@ -134,6 +134,10 @@ func buildRecipeApplyPlan(recipe *formula.Recipe, opts Options) (*beads.GraphApp
if len(recipe.Steps) == 0 {
return nil, false, "", fmt.Errorf("recipe %q has no steps", recipe.Name)
}
+ if !opts.nativeStepTopologyPrepared {
+ recipe = recipeWithNativeStepDependencies(recipe)
+ opts.nativeStepTopologyPrepared = true
+ }
vars := applyVarDefaults(opts.Vars, recipe.Vars)
priorityOverride := clonePriority(opts.PriorityOverride)
@@ -393,6 +397,13 @@ func buildFragmentApplyPlan(store beads.Store, recipe *formula.FragmentRecipe, o
if len(recipe.Steps) == 0 {
return &beads.GraphApplyPlan{}, nil
}
+ if !opts.nativeStepTopologyPrepared {
+ recipe = fragmentRecipeWithNativeStepDependencies(recipe)
+ if err := applyExternalNativeStepDependencies(store, opts.RootID, recipe.Steps, opts.ExternalDeps); err != nil {
+ return nil, err
+ }
+ opts.nativeStepTopologyPrepared = true
+ }
existingLogicalBeadIDs, err := existingLogicalBeadIDIndex(store, opts.RootID)
if err != nil {
diff --git a/internal/molecule/molecule.go b/internal/molecule/molecule.go
index f34db72031..a07cddb285 100644
--- a/internal/molecule/molecule.go
+++ b/internal/molecule/molecule.go
@@ -57,6 +57,8 @@ type Options struct {
// DeferAssignees creates assignable beads without an assignee and stores
// the intended assignee in metadata for later activation.
DeferAssignees bool
+
+ nativeStepTopologyPrepared bool
}
const (
@@ -102,6 +104,8 @@ type FragmentOptions struct {
// PriorityOverride forces every created bead to use the given priority.
// When nil, the existing workflow root's priority is inherited.
PriorityOverride *int
+
+ nativeStepTopologyPrepared bool
}
// ExternalDep binds a fragment step to an already-existing bead.
@@ -328,12 +332,15 @@ func Attach(ctx context.Context, store beads.Store, recipe *formula.Recipe, atta
recipe.Steps[0].Metadata[beadmeta.AttachFencePendingMetadataKey] = "true"
}
+ recipe = recipeWithNativeStepDependencies(recipe)
+ preserveAttachedNativeStepTopology(parentBead, recipe)
result, err := Instantiate(ctx, store, recipe, Options{
- Title: opts.Title,
- Vars: opts.Vars,
- PriorityOverride: clonePriority(parentBead.Priority),
- PreserveRootType: true,
- DeferAssignees: fencedDeferred,
+ Title: opts.Title,
+ Vars: opts.Vars,
+ PriorityOverride: clonePriority(parentBead.Priority),
+ PreserveRootType: true,
+ DeferAssignees: fencedDeferred,
+ nativeStepTopologyPrepared: true,
})
if err != nil {
return nil, fmt.Errorf("instantiate: %w", err)
@@ -759,6 +766,10 @@ func Instantiate(ctx context.Context, store beads.Store, recipe *formula.Recipe,
if len(recipe.Steps) == 0 {
return nil, fmt.Errorf("recipe %q has no steps", recipe.Name)
}
+ if !opts.nativeStepTopologyPrepared {
+ recipe = recipeWithNativeStepDependencies(recipe)
+ opts.nativeStepTopologyPrepared = true
+ }
if !opts.DeferAssignees && IsGraphApplyEnabled() {
if applier, ok := beads.GraphApplyFor(store); ok {
result, err := instantiateViaGraphApply(ctx, applier, recipe, opts)
@@ -1060,6 +1071,11 @@ func InstantiateFragment(ctx context.Context, store beads.Store, recipe *formula
if len(recipe.Steps) == 0 {
return &FragmentResult{IDMapping: map[string]string{}}, nil
}
+ recipe = fragmentRecipeWithNativeStepDependencies(recipe)
+ if err := applyExternalNativeStepDependencies(store, opts.RootID, recipe.Steps, opts.ExternalDeps); err != nil {
+ return nil, err
+ }
+ opts.nativeStepTopologyPrepared = true
priorityOverride := clonePriority(opts.PriorityOverride)
if priorityOverride == nil {
root, err := store.Get(opts.RootID)
diff --git a/internal/molecule/native_step_topology.go b/internal/molecule/native_step_topology.go
new file mode 100644
index 0000000000..e85fcaeeb5
--- /dev/null
+++ b/internal/molecule/native_step_topology.go
@@ -0,0 +1,258 @@
+package molecule
+
+import (
+ "encoding/json"
+ "fmt"
+ "maps"
+ "sort"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+ "github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/formula"
+)
+
+// recipeWithNativeStepDependencies derives the private, canonical native-step
+// topology fact from the compiled recipe graph. It intentionally has no access
+// to physical bead IDs or Needs: those are materialization details, not native
+// execution topology.
+//
+// A missing or invalid fact stays absent (UNKNOWN). A valid step with no native
+// prerequisites gets an explicit empty array (known root). The returned recipe
+// is a copy, so repeated materialization never mutates its caller's recipe.
+func recipeWithNativeStepDependencies(recipe *formula.Recipe) *formula.Recipe {
+ if recipe == nil {
+ return nil
+ }
+
+ clone := *recipe
+ clone.Steps = recipeStepsWithNativeStepDependencies(recipe.Steps, recipe.Deps)
+ return &clone
+}
+
+func fragmentRecipeWithNativeStepDependencies(recipe *formula.FragmentRecipe) *formula.FragmentRecipe {
+ if recipe == nil {
+ return nil
+ }
+ clone := *recipe
+ clone.Steps = recipeStepsWithNativeStepDependencies(recipe.Steps, recipe.Deps)
+ return &clone
+}
+
+func recipeStepsWithNativeStepDependencies(steps []formula.RecipeStep, recipeDeps []formula.RecipeDep) []formula.RecipeStep {
+ clone := make([]formula.RecipeStep, len(steps))
+ copy(clone, steps)
+ for i := range clone {
+ clone[i].Metadata = maps.Clone(steps[i].Metadata)
+ delete(clone[i].Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ if _, intentional := clone[i].Metadata[beadmeta.StepIDMetadataKey]; !intentional && validNativeStepID(clone[i].ID) {
+ if clone[i].Metadata == nil {
+ clone[i].Metadata = make(map[string]string, 1)
+ }
+ clone[i].Metadata[beadmeta.StepIDMetadataKey] = clone[i].ID
+ }
+ }
+
+ stepCount := make(map[string]int, len(clone))
+ for _, step := range clone {
+ stepCount[step.ID]++
+ }
+
+ nativeByStepID := make(map[string]string, len(clone))
+ invalidNativeIDs := make(map[string]bool)
+ for _, step := range clone {
+ nativeID := step.Metadata[beadmeta.StepIDMetadataKey]
+ if !validNativeStepID(nativeID) {
+ continue
+ }
+ if stepCount[step.ID] != 1 {
+ invalidNativeIDs[nativeID] = true
+ continue
+ }
+ nativeByStepID[step.ID] = nativeID
+ }
+
+ dependenciesByNativeID := make(map[string]map[string]struct{}, len(nativeByStepID))
+ for _, nativeID := range nativeByStepID {
+ if dependenciesByNativeID[nativeID] == nil {
+ dependenciesByNativeID[nativeID] = make(map[string]struct{})
+ }
+ }
+ for _, dep := range recipeDeps {
+ if dep.Type == "parent-child" {
+ continue
+ }
+ nativeID, ok := nativeByStepID[dep.StepID]
+ if !ok {
+ continue
+ }
+ dependencyNativeID, ok := nativeByStepID[dep.DependsOnID]
+ if !ok || dep.StepID == dep.DependsOnID {
+ invalidNativeIDs[nativeID] = true
+ continue
+ }
+ if dependencyNativeID != nativeID {
+ dependenciesByNativeID[nativeID][dependencyNativeID] = struct{}{}
+ }
+ }
+
+ for i, step := range clone {
+ nativeID, ok := nativeByStepID[step.ID]
+ if !ok || invalidNativeIDs[nativeID] {
+ continue
+ }
+ dependencies := make([]string, 0, len(dependenciesByNativeID[nativeID]))
+ for dependency := range dependenciesByNativeID[nativeID] {
+ dependencies = append(dependencies, dependency)
+ }
+ sort.Strings(dependencies)
+ encoded, err := json.Marshal(dependencies)
+ if err != nil {
+ continue
+ }
+ if clone[i].Metadata == nil {
+ clone[i].Metadata = make(map[string]string, 1)
+ }
+ clone[i].Metadata[beadmeta.NativeStepDependenciesMetadataKey] = string(encoded)
+ }
+
+ return clone
+}
+
+// validNativeStepID preserves the existing execution_step_id storage domain:
+// an exact, nonblank UTF-8 value up to 256 bytes. It deliberately does not
+// invent a new public identifier regex.
+func validNativeStepID(id string) bool {
+ return len(id) <= 256 && utf8.ValidString(id) && strings.TrimSpace(id) != ""
+}
+
+func decodeNativeStepDependencies(raw, stepID string) ([]string, bool) {
+ if raw == "" || !validNativeStepID(stepID) {
+ return nil, false
+ }
+ var dependencies []string
+ if err := json.Unmarshal([]byte(raw), &dependencies); err != nil || dependencies == nil {
+ return nil, false
+ }
+ previous := ""
+ for _, dependency := range dependencies {
+ if !validNativeStepID(dependency) || dependency == stepID || (previous != "" && dependency <= previous) {
+ return nil, false
+ }
+ previous = dependency
+ }
+ encoded, err := json.Marshal(dependencies)
+ if err != nil || string(encoded) != raw {
+ return nil, false
+ }
+ return dependencies, true
+}
+
+func normalizeNativeStepDependencies(stepID string, dependencies []string) ([]string, bool) {
+ unique := make(map[string]struct{}, len(dependencies))
+ for _, dependency := range dependencies {
+ if !validNativeStepID(dependency) {
+ return nil, false
+ }
+ if dependency != stepID {
+ unique[dependency] = struct{}{}
+ }
+ }
+ normalized := make([]string, 0, len(unique))
+ for dependency := range unique {
+ normalized = append(normalized, dependency)
+ }
+ sort.Strings(normalized)
+ return normalized, true
+}
+
+// preserveAttachedNativeStepTopology carries an immutable topology fact from a
+// control bead to a new physical occurrence of the same semantic step.
+func preserveAttachedNativeStepTopology(parent beads.Bead, recipe *formula.Recipe) {
+ if recipe == nil || len(recipe.Steps) == 0 {
+ return
+ }
+ root := &recipe.Steps[0]
+ for i := range recipe.Steps {
+ if recipe.Steps[i].IsRoot {
+ root = &recipe.Steps[i]
+ break
+ }
+ }
+ parentStepID := parent.Metadata[beadmeta.StepIDMetadataKey]
+ rootStepID := root.Metadata[beadmeta.StepIDMetadataKey]
+ if !validNativeStepID(parentStepID) || rootStepID != parentStepID {
+ return
+ }
+ raw := parent.Metadata[beadmeta.NativeStepDependenciesMetadataKey]
+ if _, complete := decodeNativeStepDependencies(raw, parentStepID); !complete {
+ delete(root.Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ return
+ }
+ root.Metadata[beadmeta.NativeStepDependenciesMetadataKey] = raw
+}
+
+// applyExternalNativeStepDependencies adds native edges for physical
+// ExternalDeps. A prerequisite contributes only when it belongs to the same
+// execution root and has a native identity; otherwise the target fact is
+// omitted rather than publishing an incomplete dependency set as authoritative.
+func applyExternalNativeStepDependencies(store beads.Store, rootID string, steps []formula.RecipeStep, externalDeps []ExternalDep) error {
+ type accumulator struct {
+ complete bool
+ dependencies []string
+ }
+ stepIndexes := make(map[string]int, len(steps))
+ for i := range steps {
+ stepIndexes[steps[i].ID] = i
+ }
+ byStep := make(map[string]*accumulator)
+ for _, dependency := range externalDeps {
+ if dependency.StepID == "" || dependency.DependsOnID == "" || dependency.Type == "parent-child" {
+ continue
+ }
+ if _, exists := stepIndexes[dependency.StepID]; !exists {
+ continue
+ }
+ current := byStep[dependency.StepID]
+ if current == nil {
+ current = &accumulator{complete: true}
+ byStep[dependency.StepID] = current
+ }
+ predecessor, err := store.Get(dependency.DependsOnID)
+ if err != nil {
+ return fmt.Errorf("resolving external dependency %q for step %q native topology: %w", dependency.DependsOnID, dependency.StepID, err)
+ }
+ predecessorStepID := predecessor.Metadata[beadmeta.StepIDMetadataKey]
+ if predecessor.Metadata[beadmeta.RootBeadIDMetadataKey] != rootID || !validNativeStepID(predecessorStepID) {
+ current.complete = false
+ continue
+ }
+ current.dependencies = append(current.dependencies, predecessorStepID)
+ }
+ for stepID, current := range byStep {
+ step := &steps[stepIndexes[stepID]]
+ if !current.complete {
+ delete(step.Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ continue
+ }
+ nativeStepID := step.Metadata[beadmeta.StepIDMetadataKey]
+ local, complete := decodeNativeStepDependencies(step.Metadata[beadmeta.NativeStepDependenciesMetadataKey], nativeStepID)
+ if !complete {
+ delete(step.Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ continue
+ }
+ dependencies, complete := normalizeNativeStepDependencies(nativeStepID, append(local, current.dependencies...))
+ if !complete {
+ delete(step.Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ continue
+ }
+ encoded, err := json.Marshal(dependencies)
+ if err != nil {
+ delete(step.Metadata, beadmeta.NativeStepDependenciesMetadataKey)
+ continue
+ }
+ step.Metadata[beadmeta.NativeStepDependenciesMetadataKey] = string(encoded)
+ }
+ return nil
+}
diff --git a/internal/molecule/native_step_topology_test.go b/internal/molecule/native_step_topology_test.go
new file mode 100644
index 0000000000..8822112c2c
--- /dev/null
+++ b/internal/molecule/native_step_topology_test.go
@@ -0,0 +1,440 @@
+package molecule
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/beadmeta"
+ "github.com/gastownhall/gascity/internal/beads"
+ "github.com/gastownhall/gascity/internal/formula"
+)
+
+func TestRecipeNativeStepDependenciesStampCanonicalRecipeTopology(t *testing.T) {
+ recipe := &formula.Recipe{
+ Steps: []formula.RecipeStep{
+ {ID: "workflow", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "native-root"}},
+ {ID: "prepare", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "native-prepare"}},
+ {ID: "build", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "native-build"}},
+ },
+ Deps: []formula.RecipeDep{
+ {StepID: "prepare", DependsOnID: "workflow", Type: "parent-child"},
+ {StepID: "build", DependsOnID: "prepare", Type: "blocks"},
+ },
+ }
+
+ stamped := recipeWithNativeStepDependencies(recipe)
+ if got, want := stamped.Steps[0].Metadata["gc.native_step_dependencies.v1"], "[]"; got != want {
+ t.Fatalf("root topology = %q, want %q", got, want)
+ }
+ if got, want := stamped.Steps[1].Metadata["gc.native_step_dependencies.v1"], "[]"; got != want {
+ t.Fatalf("parent-only topology = %q, want %q", got, want)
+ }
+ if got, want := stamped.Steps[2].Metadata["gc.native_step_dependencies.v1"], `["native-prepare"]`; got != want {
+ t.Fatalf("build topology = %q, want %q", got, want)
+ }
+ if !reflect.DeepEqual(recipe.Steps[2].Metadata, map[string]string{beadmeta.StepIDMetadataKey: "native-build"}) {
+ t.Fatalf("input recipe mutated: %#v", recipe.Steps[2].Metadata)
+ }
+}
+
+func TestRecipeNativeStepDependenciesOmitUnsafeTopology(t *testing.T) {
+ recipe := &formula.Recipe{
+ Steps: []formula.RecipeStep{
+ {ID: "source", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "native-source"}},
+ {ID: "target", Metadata: map[string]string{beadmeta.StepIDMetadataKey: " "}},
+ {ID: "self", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "native-self"}},
+ {ID: "empty", Metadata: map[string]string{beadmeta.StepIDMetadataKey: ""}},
+ },
+ Deps: []formula.RecipeDep{
+ {StepID: "source", DependsOnID: "target", Type: "blocks"},
+ {StepID: "self", DependsOnID: "self", Type: "blocks"},
+ },
+ }
+
+ stamped := recipeWithNativeStepDependencies(recipe)
+ for _, index := range []int{0, 1, 2, 3} {
+ if got := stamped.Steps[index].Metadata["gc.native_step_dependencies.v1"]; got != "" {
+ t.Fatalf("step %q topology = %q, want omitted", stamped.Steps[index].ID, got)
+ }
+ }
+}
+
+func TestCompiledGraphRecipeStampsNativeStepTopology(t *testing.T) {
+ formulaDir := t.TempDir()
+ const formulaName = "native-step-topology"
+ formulaBytes := []byte(`formula = "native-step-topology"
+
+[requires]
+formula_compiler = ">=2.0.0"
+
+[[steps]]
+id = "first"
+title = "First"
+
+[[steps]]
+id = "second"
+title = "Second"
+needs = ["first"]
+`)
+ if err := os.WriteFile(filepath.Join(formulaDir, formulaName+".toml"), formulaBytes, 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ recipe, err := formula.Compile(context.Background(), formulaName, []string{formulaDir}, nil)
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ plan, _, _, err := buildRecipeApplyPlan(recipe, Options{})
+ if err != nil {
+ t.Fatalf("buildRecipeApplyPlan: %v", err)
+ }
+ nodes := make(map[string]beads.GraphApplyNode, len(plan.Nodes))
+ for _, node := range plan.Nodes {
+ nodes[node.Key] = node
+ }
+ nativeByRecipeID := make(map[string]string, len(recipe.Steps))
+ for _, step := range recipe.Steps {
+ want := step.Metadata[beadmeta.StepIDMetadataKey]
+ if want == "" {
+ want = step.ID
+ }
+ nativeByRecipeID[step.ID] = want
+ if got := nodes[step.ID].Metadata[beadmeta.StepIDMetadataKey]; got != want {
+ t.Fatalf("node %q gc.step_id = %q, want %q", step.ID, got, want)
+ }
+ }
+ for _, step := range recipe.Steps {
+ dependencies := make([]string, 0)
+ for _, dep := range recipe.Deps {
+ if dep.StepID == step.ID && dep.Type != "parent-child" {
+ dependencies = append(dependencies, nativeByRecipeID[dep.DependsOnID])
+ }
+ }
+ sort.Strings(dependencies)
+ want, err := json.Marshal(dependencies)
+ if err != nil {
+ t.Fatalf("marshal expected topology: %v", err)
+ }
+ if got := nodes[step.ID].Metadata[beadmeta.NativeStepDependenciesMetadataKey]; got != string(want) {
+ t.Fatalf("node %q topology = %q, want %q", step.ID, got, want)
+ }
+ }
+}
+
+func TestCompiledReviewQuorumCollapsesRetryMachineryIntoNativeSteps(t *testing.T) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ searchDir := filepath.Join(cwd, "..", "bootstrap", "packs", "core", "formulas")
+ recipe, err := formula.Compile(context.Background(), "mol-review-quorum", []string{searchDir}, map[string]string{
+ "subject": "PR-123",
+ "lane_one_id": "primary",
+ "lane_one_provider": "provider-a",
+ "lane_one_model": "model-a",
+ "lane_one_target": "target-a",
+ "lane_two_id": "secondary",
+ "lane_two_provider": "provider-b",
+ "lane_two_model": "model-b",
+ "lane_two_target": "target-b",
+ "synthesis_target": "review-synthesis",
+ })
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+
+ plan, _, _, err := buildRecipeApplyPlan(recipe, Options{})
+ if err != nil {
+ t.Fatalf("buildRecipeApplyPlan: %v", err)
+ }
+ nodes := make(map[string]beads.GraphApplyNode, len(plan.Nodes))
+ for _, node := range plan.Nodes {
+ nodes[node.Key] = node
+ }
+
+ for _, key := range []string{
+ "mol-review-quorum.review-lane-one",
+ "mol-review-quorum.review-lane-one.attempt.1",
+ "mol-review-quorum.review-lane-two",
+ "mol-review-quorum.review-lane-two.attempt.1",
+ } {
+ if got, want := nodes[key].Metadata[beadmeta.NativeStepDependenciesMetadataKey], "[]"; got != want {
+ t.Fatalf("node %q topology = %q, want %q", key, got, want)
+ }
+ }
+ if got, want := nodes["mol-review-quorum.synthesize-review-quorum"].Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["review-lane-one","review-lane-two"]`; got != want {
+ t.Fatalf("synthesis topology = %q, want %q", got, want)
+ }
+}
+
+func TestNativeStepDependenciesMaterializeThroughGraphAndSequentialPaths(t *testing.T) {
+ recipe := &formula.Recipe{
+ Name: "native-topology",
+ Steps: []formula.RecipeStep{
+ {ID: "native-topology", IsRoot: true, Metadata: map[string]string{beadmeta.StepIDMetadataKey: "root"}},
+ {ID: "native-topology.first", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "first"}},
+ {ID: "native-topology.second", Metadata: map[string]string{beadmeta.StepIDMetadataKey: "second"}},
+ },
+ Deps: []formula.RecipeDep{{StepID: "native-topology.second", DependsOnID: "native-topology.first", Type: "blocks"}},
+ }
+
+ plan, _, _, err := buildRecipeApplyPlan(recipe, Options{})
+ if err != nil {
+ t.Fatalf("buildRecipeApplyPlan: %v", err)
+ }
+ if got, want := plan.Nodes[2].Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["first"]`; got != want {
+ t.Fatalf("graph node topology = %q, want %q", got, want)
+ }
+
+ store := beads.NewMemStore()
+ result, err := Instantiate(context.Background(), store, recipe, Options{})
+ if err != nil {
+ t.Fatalf("Instantiate: %v", err)
+ }
+ second, err := store.Get(result.IDMapping["native-topology.second"])
+ if err != nil {
+ t.Fatalf("Get second: %v", err)
+ }
+ if got, want := second.Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["first"]`; got != want {
+ t.Fatalf("sequential bead topology = %q, want %q", got, want)
+ }
+}
+
+func TestAttachPreservesNativeStepDependenciesAcrossRetryAttempts(t *testing.T) {
+ store := beads.NewMemStore()
+ control, err := store.Create(beads.Bead{
+ Title: "Build retry control",
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "build",
+ beadmeta.StepRefMetadataKey: "workflow.build",
+ beadmeta.NativeStepDependenciesMetadataKey: `["prepare"]`,
+ },
+ })
+ if err != nil {
+ t.Fatalf("create control: %v", err)
+ }
+ recipe := &formula.Recipe{
+ Name: "workflow.build.attempt.2",
+ Steps: []formula.RecipeStep{{
+ ID: "workflow.build.attempt.2",
+ Title: "Build",
+ IsRoot: true,
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "build",
+ beadmeta.StepRefMetadataKey: "workflow.build.attempt.2",
+ },
+ }},
+ }
+
+ result, err := Attach(context.Background(), store, recipe, control.ID, AttachOptions{})
+ if err != nil {
+ t.Fatalf("Attach: %v", err)
+ }
+ attempt, err := store.Get(result.RootID)
+ if err != nil {
+ t.Fatalf("get attempt: %v", err)
+ }
+ if got, want := attempt.Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["prepare"]`; got != want {
+ t.Fatalf("retry attempt topology = %q, want immutable %q", got, want)
+ }
+}
+
+func TestAttachKeepsRetryTopologyUnknownWhenParentTopologyIsUnknown(t *testing.T) {
+ store := beads.NewMemStore()
+ control, err := store.Create(beads.Bead{
+ Title: "Build retry control",
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "build",
+ beadmeta.StepRefMetadataKey: "workflow.build",
+ },
+ })
+ if err != nil {
+ t.Fatalf("create control: %v", err)
+ }
+ recipe := &formula.Recipe{
+ Name: "workflow.build.attempt.2",
+ Steps: []formula.RecipeStep{{
+ ID: "workflow.build.attempt.2",
+ Title: "Build",
+ IsRoot: true,
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "build",
+ beadmeta.StepRefMetadataKey: "workflow.build.attempt.2",
+ },
+ }},
+ }
+
+ result, err := Attach(context.Background(), store, recipe, control.ID, AttachOptions{})
+ if err != nil {
+ t.Fatalf("Attach: %v", err)
+ }
+ attempt, err := store.Get(result.RootID)
+ if err != nil {
+ t.Fatalf("get attempt: %v", err)
+ }
+ if got, present := attempt.Metadata[beadmeta.NativeStepDependenciesMetadataKey]; present {
+ t.Fatalf("retry attempt topology = %q, want omitted UNKNOWN", got)
+ }
+}
+
+func TestInstantiateFragmentIncludesCompleteExternalNativeStepDependencies(t *testing.T) {
+ store := beads.NewMemStore()
+ root, err := store.Create(beads.Bead{Title: "Workflow"})
+ if err != nil {
+ t.Fatalf("create root: %v", err)
+ }
+ predecessor, err := store.Create(beads.Bead{
+ Title: "Prepare",
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "prepare",
+ beadmeta.RootBeadIDMetadataKey: root.ID,
+ },
+ })
+ if err != nil {
+ t.Fatalf("create predecessor: %v", err)
+ }
+ fragment := &formula.FragmentRecipe{
+ Name: "late-build",
+ Steps: []formula.RecipeStep{{ID: "build", Title: "Build"}},
+ Entries: []string{"build"},
+ Sinks: []string{"build"},
+ }
+ opts := FragmentOptions{
+ RootID: root.ID,
+ ExternalDeps: []ExternalDep{{
+ StepID: "build",
+ DependsOnID: predecessor.ID,
+ Type: "blocks",
+ }},
+ }
+ plan, err := buildFragmentApplyPlan(store, fragment, opts)
+ if err != nil {
+ t.Fatalf("buildFragmentApplyPlan: %v", err)
+ }
+ if got, want := plan.Nodes[0].Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["prepare"]`; got != want {
+ t.Fatalf("graph fragment topology = %q, want %q", got, want)
+ }
+
+ result, err := InstantiateFragment(context.Background(), store, fragment, opts)
+ if err != nil {
+ t.Fatalf("InstantiateFragment: %v", err)
+ }
+ build, err := store.Get(result.IDMapping["build"])
+ if err != nil {
+ t.Fatalf("get build: %v", err)
+ }
+ if got, want := build.Metadata[beadmeta.NativeStepDependenciesMetadataKey], `["prepare"]`; got != want {
+ t.Fatalf("fragment topology = %q, want %q", got, want)
+ }
+}
+
+func TestInstantiateFragmentOmitsExternalTopologyOutsideExactRoot(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ predecessorRoot string
+ }{
+ {name: "missing root"},
+ {name: "foreign root", predecessorRoot: "gcg-foreign"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ store := beads.NewMemStore()
+ root, err := store.Create(beads.Bead{Title: "Workflow"})
+ if err != nil {
+ t.Fatalf("create root: %v", err)
+ }
+ predecessor, err := store.Create(beads.Bead{
+ Title: "Prepare",
+ Metadata: map[string]string{
+ beadmeta.StepIDMetadataKey: "prepare",
+ beadmeta.RootBeadIDMetadataKey: tc.predecessorRoot,
+ },
+ })
+ if err != nil {
+ t.Fatalf("create predecessor: %v", err)
+ }
+ fragment := &formula.FragmentRecipe{
+ Name: "late-build",
+ Steps: []formula.RecipeStep{{ID: "build", Title: "Build"}},
+ Entries: []string{"build"},
+ Sinks: []string{"build"},
+ }
+ opts := FragmentOptions{
+ RootID: root.ID,
+ ExternalDeps: []ExternalDep{{
+ StepID: "build",
+ DependsOnID: predecessor.ID,
+ Type: "blocks",
+ }},
+ }
+
+ plan, err := buildFragmentApplyPlan(store, fragment, opts)
+ if err != nil {
+ t.Fatalf("buildFragmentApplyPlan: %v", err)
+ }
+ if got, present := plan.Nodes[0].Metadata[beadmeta.NativeStepDependenciesMetadataKey]; present {
+ t.Fatalf("graph fragment topology = %q, want omitted UNKNOWN", got)
+ }
+
+ result, err := InstantiateFragment(context.Background(), store, fragment, opts)
+ if err != nil {
+ t.Fatalf("InstantiateFragment: %v", err)
+ }
+ build, err := store.Get(result.IDMapping["build"])
+ if err != nil {
+ t.Fatalf("get build: %v", err)
+ }
+ if got, present := build.Metadata[beadmeta.NativeStepDependenciesMetadataKey]; present {
+ t.Fatalf("fragment topology = %q, want omitted UNKNOWN", got)
+ }
+ })
+ }
+}
+
+func TestInstantiateFragmentOmitsTopologyWhenExternalNativeStepIsUnknown(t *testing.T) {
+ store := beads.NewMemStore()
+ root, err := store.Create(beads.Bead{Title: "Workflow"})
+ if err != nil {
+ t.Fatalf("create root: %v", err)
+ }
+ unknownPredecessor, err := store.Create(beads.Bead{Title: "Unidentified prerequisite"})
+ if err != nil {
+ t.Fatalf("create predecessor: %v", err)
+ }
+ fragment := &formula.FragmentRecipe{
+ Name: "late-build",
+ Steps: []formula.RecipeStep{{ID: "build", Title: "Build"}},
+ Entries: []string{"build"},
+ Sinks: []string{"build"},
+ }
+ opts := FragmentOptions{
+ RootID: root.ID,
+ ExternalDeps: []ExternalDep{{
+ StepID: "build",
+ DependsOnID: unknownPredecessor.ID,
+ Type: "blocks",
+ }},
+ }
+ plan, err := buildFragmentApplyPlan(store, fragment, opts)
+ if err != nil {
+ t.Fatalf("buildFragmentApplyPlan: %v", err)
+ }
+ if got, present := plan.Nodes[0].Metadata[beadmeta.NativeStepDependenciesMetadataKey]; present {
+ t.Fatalf("graph fragment topology = %q, want omitted UNKNOWN", got)
+ }
+
+ result, err := InstantiateFragment(context.Background(), store, fragment, opts)
+ if err != nil {
+ t.Fatalf("InstantiateFragment: %v", err)
+ }
+ build, err := store.Get(result.IDMapping["build"])
+ if err != nil {
+ t.Fatalf("get build: %v", err)
+ }
+ if got, present := build.Metadata[beadmeta.NativeStepDependenciesMetadataKey]; present {
+ t.Fatalf("fragment topology = %q, want omitted UNKNOWN", got)
+ }
+}
diff --git a/internal/pgauth/no_external_env_test.go b/internal/pgauth/no_external_env_test.go
index 7e86de69c8..20d400397b 100644
--- a/internal/pgauth/no_external_env_test.go
+++ b/internal/pgauth/no_external_env_test.go
@@ -41,9 +41,16 @@ func TestNoDirectPostgresEnvReadsOutsidePgauth(t *testing.T) {
if base == ".git" || base == "vendor" || base == ".claude" || base == ".beads" || base == ".gc" || base == "worktrees" || strings.HasPrefix(base, ".beads-src") || strings.HasPrefix(base, "node_modules") {
return filepath.SkipDir
}
- // Skip git worktrees embedded in the repo (have a .git file, not dir).
- if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
- return filepath.SkipDir
+ // Skip git worktrees embedded in the repo (have a .git file, not
+ // dir) — but never apply this to root itself. gc agent sessions run
+ // from inside a worktree, so root legitimately has a .git file
+ // rather than a .git directory; skipping on that condition here
+ // would SkipDir the walk's very first entry and silently visit
+ // zero files.
+ if path != root {
+ if fi, serr := os.Stat(filepath.Join(path, ".git")); serr == nil && !fi.IsDir() {
+ return filepath.SkipDir
+ }
}
return nil
}
diff --git a/internal/pidutil/cmdline_portable_test.go b/internal/pidutil/cmdline_portable_test.go
new file mode 100644
index 0000000000..d09ab32a8c
--- /dev/null
+++ b/internal/pidutil/cmdline_portable_test.go
@@ -0,0 +1,171 @@
+package pidutil
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+// AliveWithCmdline answers "is this PID the process I think it is" by comparing
+// argv. It short-circuited to `return true` on every non-Linux host, because
+// Cmdline read only /proc//cmdline.
+//
+// That turns an identity check into a bare existence check. Its callers use it
+// to decide whether the PID in a poller pidfile is still *their* poller: on a
+// host with high PID churn, a recycled PID owned by an unrelated live process
+// then reads as "poller already running", and the caller returns success
+// without starting one — cmd/gc/cmd_nudge.go returns 0, internal/session's
+// submit path returns nil. Nudge and submit delivery stop for that target with
+// no error and nothing logged.
+//
+// These tests pin the identity semantics on every platform. The existing
+// coverage asserted them and then skipped off Linux, which is why the inversion
+// survived.
+
+// spawnSleeper starts a long-lived child and returns its pid. argv is exactly
+// ["sleep","60"], which is what both the /proc and ps paths must report.
+func spawnSleeper(t *testing.T) int {
+ t.Helper()
+ cmd := exec.Command("sleep", "60")
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ })
+ // Give the exec a moment so the argv is the sleeper's, not the shell's.
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if argv, err := Cmdline(cmd.Process.Pid); err == nil && len(argv) > 0 {
+ break
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ return cmd.Process.Pid
+}
+
+// TestAliveWithCmdline_RejectsLivePIDWithNonMatchingArgv is the defect, stated
+// directly: a live process whose argv does NOT match must be rejected. Off Linux
+// this returned true, which is what let an unrelated recycled PID pass as the
+// caller's own poller.
+func TestAliveWithCmdline_RejectsLivePIDWithNonMatchingArgv(t *testing.T) {
+ pid := spawnSleeper(t)
+
+ got := AliveWithCmdline(pid, func(argv []string) bool {
+ return ArgvContainsSequence(argv, "definitely-not-in-this-argv")
+ })
+
+ if got {
+ t.Fatalf("AliveWithCmdline(%d, non-matching) = true on %s; an unrelated live PID passes as the caller's own process", pid, runtime.GOOS)
+ }
+}
+
+// TestAliveWithCmdline_AcceptsMatchingArgv is the over-correction guard: the
+// check must still say yes to the real process. Passes before and after.
+func TestAliveWithCmdline_AcceptsMatchingArgv(t *testing.T) {
+ pid := spawnSleeper(t)
+
+ got := AliveWithCmdline(pid, func(argv []string) bool {
+ return ArgvContainsSequence(argv, "sleep", "60")
+ })
+
+ if !got {
+ argv, err := Cmdline(pid)
+ t.Fatalf("AliveWithCmdline(%d, matching) = false; argv=%q err=%v", pid, argv, err)
+ }
+}
+
+// TestCmdline_ReturnsArgvOnThisHost is the regression test for the cause rather
+// than the symptom: Cmdline must produce argv on the host it runs on. It
+// returned an error on every non-Linux host, which is what forced the
+// short-circuit above.
+func TestCmdline_ReturnsArgvOnThisHost(t *testing.T) {
+ argv, err := Cmdline(os.Getpid())
+ if err != nil {
+ t.Fatalf("Cmdline(self) on %s: %v", runtime.GOOS, err)
+ }
+ if len(argv) == 0 {
+ t.Fatalf("Cmdline(self) on %s returned no argv", runtime.GOOS)
+ }
+}
+
+// TestAliveWithCmdline_FalseForDeadPID and _NilMatch pin the two answers that
+// must not change.
+func TestAliveWithCmdline_FalseForDeadPID(t *testing.T) {
+ cmd := exec.Command("sh", "-c", "exit 0")
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ pid := cmd.Process.Pid
+ _ = cmd.Wait()
+
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if !AliveWithCmdline(pid, func([]string) bool { return true }) {
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatalf("AliveWithCmdline(%d) stayed true for an exited child", pid)
+}
+
+func TestAliveWithCmdline_NilMatchIsFalse(t *testing.T) {
+ if AliveWithCmdline(os.Getpid(), nil) {
+ t.Fatal("AliveWithCmdline(self, nil) = true, want false")
+ }
+}
+
+// TestCmdline_FailsClosedWhenUnreadable covers the direction that matters for
+// safety here. An unreadable process must NOT be reported as matching: the
+// caller then assumes no poller is running and starts one. A duplicate poller is
+// recoverable; a silently absent one is not.
+func TestCmdline_FailsClosedWhenUnreadable(t *testing.T) {
+ if runtime.GOOS == "linux" {
+ t.Skip("on linux /proc answers directly, so the ps stub cannot make argv unreadable")
+ }
+
+ binDir := t.TempDir()
+ // A ps that produces nothing, so the non-/proc path has no argv to offer.
+ if err := os.WriteFile(filepath.Join(binDir, "ps"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ if AliveWithCmdline(os.Getpid(), func([]string) bool { return true }) {
+ t.Fatal("AliveWithCmdline = true with no readable argv; must fail closed so the caller starts its poller")
+ }
+}
+
+// TestPSCmdlineParsesOwnArgv exercises the ps parse path directly. Calling
+// psCmdline bypasses Cmdline's /proc shortcut, so the parser this PR adds
+// gets real coverage on linux runners too — otherwise it runs nowhere in CI.
+func TestPSCmdlineParsesOwnArgv(t *testing.T) {
+ argv, err := psCmdline(os.Getpid())
+ if err != nil {
+ t.Fatalf("psCmdline(self) on %s: %v", runtime.GOOS, err)
+ }
+ if len(argv) == 0 || !strings.Contains(filepath.Base(argv[0]), "pidutil") {
+ t.Fatalf("psCmdline(self) = %q, want test binary argv", argv)
+ }
+}
+
+// TestPSCmdlineIsBounded mirrors the existing zombie-probe guard: a hung ps must
+// not stall a caller that runs on a reconciler tick.
+func TestPSCmdlineIsBounded(t *testing.T) {
+ binDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(binDir, "ps"), []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ start := time.Now()
+ _, _ = psCmdline(os.Getpid())
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Fatalf("psCmdline took %s, want a bounded timeout", elapsed)
+ }
+}
diff --git a/internal/pidutil/pidutil.go b/internal/pidutil/pidutil.go
index 00510ab518..ef361385eb 100644
--- a/internal/pidutil/pidutil.go
+++ b/internal/pidutil/pidutil.go
@@ -8,14 +8,24 @@ import (
"os"
"os/exec"
"path/filepath"
- "runtime"
"strconv"
"strings"
"syscall"
"time"
)
-const psZombieTimeout = 100 * time.Millisecond
+const (
+ psZombieTimeout = 100 * time.Millisecond
+ childEnumTimeout = 1 * time.Second
+ // psStartTimeTimeout bounds the portable start-time probe. Callers sit in a
+ // post-SIGKILL reap loop, so a hung ps must not stall them.
+ psStartTimeTimeout = 1 * time.Second
+)
+
+// psCmdlineTimeout bounds the portable argv probe. Callers run on reconciler
+// ticks, so a hung ps must not stall them; a timeout yields no argv, which the
+// identity check treats as "cannot confirm" and rejects.
+const psCmdlineTimeout = time.Second
// Alive reports whether a PID exists and is not a zombie.
func Alive(pid int) bool {
@@ -43,9 +53,12 @@ func Alive(pid int) bool {
// recycled PID from the original target. The kernel never reuses a (pid,
// starttime) pair for the lifetime of a boot, so a changed start time on the
// same PID proves the original process is gone and an unrelated one now holds
-// the number. It returns an error on platforms without /proc (e.g. darwin) or
-// when the process record is unreadable; callers treat that as "no identity
-// signal available" and fall back to plain liveness.
+// the number. Where /proc is unavailable (e.g. darwin) it falls back to ps,
+// which reports a wall-clock start date rather than jiffies; the token is
+// opaque and only ever compared against another read the same way on the same
+// host, so the differing format does not matter. It returns an error only when
+// neither mechanism can answer; callers treat that as "no identity signal
+// available" and fall back to plain liveness.
//
// The comm field (field 2) is wrapped in parens and may itself contain spaces
// and parens, so parsing anchors on the final ')' and counts fields from
@@ -57,7 +70,7 @@ func StartTime(pid int) (string, error) {
}
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
if err != nil {
- return "", err
+ return psStartTime(pid)
}
stat := string(data)
rparen := strings.LastIndexByte(stat, ')')
@@ -79,11 +92,11 @@ func StartTime(pid int) (string, error) {
// wrongly report the (dead) target as still alive.
//
// An empty startTime disables the identity check and falls back to Alive — used
-// on platforms without /proc start-time support (darwin) or when the original
-// start time could not be captured before the wait. A non-empty startTime that
-// no longer matches means the PID was recycled: the original target is dead, so
-// this returns false. When the current start time cannot be read despite Alive
-// reporting true (a transient race, no /proc), it keeps the conservative Alive
+// when the original start time could not be captured before the wait. A
+// non-empty startTime that no longer matches means the PID was recycled: the
+// original target is dead, so this returns false. When the current start time
+// cannot be read despite Alive reporting true (a transient race, or a host
+// where neither /proc nor ps can answer), it keeps the conservative Alive
// answer rather than inventing a death.
func AliveWithStartTime(pid int, startTime string) bool {
if !Alive(pid) {
@@ -100,8 +113,17 @@ func AliveWithStartTime(pid int, startTime string) bool {
}
// AliveWithCmdline reports whether a PID exists, is not a zombie, and its
-// command line satisfies match. On platforms without /proc cmdline support it
-// falls back to Alive so callers preserve existing non-Linux behavior.
+// command line satisfies match.
+//
+// It used to return true unconditionally off Linux, because Cmdline read only
+// /proc. That turned an identity check into a bare existence check on those
+// hosts: callers use this to decide whether the PID in a pidfile is still THEIR
+// process, so a recycled PID owned by an unrelated live process passed the
+// check, and the caller skipped work it should have done. Cmdline is portable
+// now, so the platform branch is gone.
+//
+// An unreadable argv yields false — never a match. Callers treat "not my
+// process" as "do the work", which is the recoverable direction.
func AliveWithCmdline(pid int, match func([]string) bool) bool {
if !Alive(pid) {
return false
@@ -109,9 +131,6 @@ func AliveWithCmdline(pid int, match func([]string) bool) bool {
if match == nil {
return false
}
- if runtime.GOOS != "linux" {
- return true
- }
argv, err := Cmdline(pid)
if err != nil {
return false
@@ -159,13 +178,15 @@ func ArgvHasFlagValue(argv []string, flag, value string) bool {
return false
}
-// Cmdline returns a PID's command line from /proc, normalized through
-// NormalizeArgv. It returns an error on hosts without /proc cmdline support
-// or when the process record is unreadable.
+// Cmdline returns a PID's command line, normalized through NormalizeArgv.
+// It reads /proc//cmdline where available and otherwise falls back to ps,
+// which is how the rest of this repo already reads another process's argv
+// (see the ps -o args= call sites in cmd/gc and internal/runtime/tmux).
+// It returns an error when no mechanism can read the process record.
func Cmdline(pid int) ([]string, error) {
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
if err != nil {
- return nil, err
+ return psCmdline(pid)
}
trimmed := strings.TrimRight(string(data), "\x00")
if trimmed == "" {
@@ -190,6 +211,110 @@ func NormalizeArgv(argv []string) []string {
return out
}
+// ChildPIDs returns the pids of all live direct child processes of parent,
+// enumerated portably via `ps -axo pid=,ppid=` rather than a /proc walk, so
+// it works on darwin as well as linux. It returns an error when the ps
+// invocation itself fails or times out, so callers can tell "enumeration
+// ran and found nothing" apart from "enumeration did not run" — collapsing
+// the two into an empty slice would let an unavailable check masquerade as
+// a clean result.
+//
+// ps is itself alive, and a child of the caller, at the instant it captures
+// the process table — so a caller checking its own children (parent ==
+// os.Getpid(), the pattern this package's callers use for self leak checks)
+// always sees ps's own transient pid/ppid row alongside any real children.
+// The enumeration helper's own pid is excluded below so it can never
+// masquerade as a leaked child.
+func ChildPIDs(parent int) ([]int, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), childEnumTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ps", "-axo", "pid=,ppid=")
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("pidutil: ps enumeration failed: %w", err)
+ }
+ selfPID := -1
+ if cmd.Process != nil {
+ selfPID = cmd.Process.Pid
+ }
+
+ var children []int
+ for _, line := range strings.Split(string(out), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) != 2 {
+ continue
+ }
+ pid, errPID := strconv.Atoi(fields[0])
+ ppid, errPPID := strconv.Atoi(fields[1])
+ if errPID != nil || errPPID != nil {
+ continue
+ }
+ if pid == selfPID {
+ continue
+ }
+ if ppid == parent {
+ children = append(children, pid)
+ }
+ }
+ return children, nil
+}
+
+// psStartTime reads a PID's start time with ps, for hosts without /proc.
+//
+// The two mechanisms return different formats — /proc gives jiffies since boot,
+// ps gives a wall-clock date — and that is fine, because the identity check only
+// ever compares a value captured earlier against one read later on the SAME
+// host, so the same mechanism produces both. The values are never compared
+// across platforms.
+//
+// One granularity limitation: ps -o lstart= has one-second resolution, so a PID
+// recycled within the same second as its predecessor started would compare equal
+// and the reuse would go undetected. That is strictly narrower than the window
+// the check closes today, where the identity check does not run at all off
+// Linux, and the consequence of a miss is the pre-existing conservative answer
+// rather than a wrong death.
+func psStartTime(pid int) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), psStartTimeTimeout)
+ defer cancel()
+
+ out, err := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "lstart=").Output()
+ if err != nil {
+ return "", fmt.Errorf("reading start time for pid %d via ps: %w", pid, err)
+ }
+ identity := strings.TrimSpace(string(out))
+ if identity == "" {
+ return "", fmt.Errorf("no start time reported for pid %d", pid)
+ }
+ return identity, nil
+}
+
+// psCmdline reads a PID's argv with ps, for hosts without /proc.
+//
+// One accepted limitation: ps renders argv as a single space-joined string, so
+// an argument containing a space is split into two. The matchers in this package
+// compare flags and their values (ArgvContainsSequence, ArgvHasFlagValue), and
+// the identifiers they match on — session names, targets — do not contain
+// spaces. Reading argv exactly on darwin needs KERN_PROCARGS2 via cgo, which is
+// not worth it for that gap. A mis-split argv fails the match, and failing the
+// match is the safe direction for every caller.
+//
+// -ww asks ps for full width, since a truncated argv fails the match on BSD ps.
+func psCmdline(pid int) ([]string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), psCmdlineTimeout)
+ defer cancel()
+
+ out, err := exec.CommandContext(ctx, "ps", "-ww", "-o", "args=", "-p", strconv.Itoa(pid)).Output()
+ if err != nil {
+ return nil, fmt.Errorf("reading argv for pid %d via ps: %w", pid, err)
+ }
+ fields := strings.Fields(string(out))
+ if len(fields) == 0 {
+ return nil, fmt.Errorf("no argv reported for pid %d", pid)
+ }
+ return NormalizeArgv(fields), nil
+}
+
func psReportsZombie(pid int) bool {
ctx, cancel := context.WithTimeout(context.Background(), psZombieTimeout)
defer cancel()
diff --git a/internal/pidutil/pidutil_test.go b/internal/pidutil/pidutil_test.go
index 26c64d7cf7..0366dc643d 100644
--- a/internal/pidutil/pidutil_test.go
+++ b/internal/pidutil/pidutil_test.go
@@ -1,10 +1,12 @@
package pidutil
import (
+ "fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
+ "slices"
"strings"
"testing"
"time"
@@ -49,9 +51,6 @@ func TestPSReportsZombieReturnsWhenPSHangs(t *testing.T) {
}
func TestStartTimeStableForLivePID(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("start-time reads /proc//stat on linux")
- }
first, err := StartTime(os.Getpid())
if err != nil {
t.Fatalf("StartTime(%d): %v", os.Getpid(), err)
@@ -79,9 +78,6 @@ func TestStartTimeRejectsInvalidPID(t *testing.T) {
// one (the recycled-PID case) reports dead even though the PID is live, and an
// empty start time falls back to plain liveness.
func TestAliveWithStartTimeDisambiguatesRecycledPID(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("start-time identity uses /proc on linux")
- }
self := os.Getpid()
st, err := StartTime(self)
if err != nil {
@@ -114,10 +110,6 @@ func TestAliveWithStartTimeDeadPID(t *testing.T) {
}
func TestAliveWithCmdlineRejectsUnrelatedLivePID(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("cmdline detection uses /proc on linux")
- }
-
if AliveWithCmdline(os.Getpid(), func(_ []string) bool {
return false
}) {
@@ -126,10 +118,6 @@ func TestAliveWithCmdlineRejectsUnrelatedLivePID(t *testing.T) {
}
func TestAliveWithCmdlineAcceptsMatchingLivePID(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("cmdline detection uses /proc on linux")
- }
-
if !AliveWithCmdline(os.Getpid(), func(argv []string) bool {
return len(argv) > 0 && strings.Contains(filepath.Base(argv[0]), "pidutil")
}) {
@@ -138,10 +126,6 @@ func TestAliveWithCmdlineAcceptsMatchingLivePID(t *testing.T) {
}
func TestCmdlineReturnsOwnArgv(t *testing.T) {
- if runtime.GOOS != "linux" {
- t.Skip("cmdline detection uses /proc on linux")
- }
-
argv, err := Cmdline(os.Getpid())
if err != nil {
t.Fatalf("Cmdline(%d): %v", os.Getpid(), err)
@@ -188,6 +172,89 @@ func TestArgvContainsSequence(t *testing.T) {
}
}
+// TestChildPIDsFindsLiveChild is a RED test for ga-gxmz9n: ChildPIDs must
+// enumerate a real live direct child portably (no /proc dependency), on
+// linux and darwin alike.
+func TestChildPIDsFindsLiveChild(t *testing.T) {
+ cmd := exec.Command("sleep", "5")
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start sleep: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ })
+
+ deadline := time.Now().Add(2 * time.Second)
+ var pids []int
+ for time.Now().Before(deadline) {
+ var err error
+ pids, err = ChildPIDs(os.Getpid())
+ if err != nil {
+ t.Fatalf("ChildPIDs(%d): %v", os.Getpid(), err)
+ }
+ if slices.Contains(pids, cmd.Process.Pid) {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("ChildPIDs(%d) = %v, want to contain live child pid %d", os.Getpid(), pids, cmd.Process.Pid)
+}
+
+// TestChildPIDsReturnsErrorWhenPSHangs is a RED test for ga-gxmz9n's binding
+// constraint: when enumeration cannot complete, ChildPIDs must report an
+// error rather than silently returning an empty (falsely "no children")
+// result — otherwise a leak-detection caller cannot tell "checked, found
+// none" apart from "never actually checked". Mirrors
+// TestPSReportsZombieReturnsWhenPSHangs's PATH-shadowing technique.
+func TestChildPIDsReturnsErrorWhenPSHangs(t *testing.T) {
+ binDir := t.TempDir()
+ psPath := filepath.Join(binDir, "ps")
+ if err := os.WriteFile(psPath, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ start := time.Now()
+ pids, err := ChildPIDs(os.Getpid())
+ if err == nil {
+ t.Fatalf("ChildPIDs with a hanging ps: got pids=%v err=nil, want a non-nil error", pids)
+ }
+ if elapsed := time.Since(start); elapsed > 5*time.Second {
+ t.Fatalf("ChildPIDs took %s, want bounded timeout", elapsed)
+ }
+}
+
+// TestChildPIDsExcludesItsOwnEnumerationHelper is a regression test: ps is
+// itself alive, and a child of the caller, at the instant it captures the
+// process table, so an unfiltered ChildPIDs(os.Getpid()) always reports at
+// least one phantom "child" — the transient ps invocation itself — even
+// when no real child exists. This is exactly the self-monitoring pattern
+// this package's callers use for leak checks (ChildPIDs(os.Getpid())), and
+// it produced a false-positive "leaked child" on every run of
+// internal/workspacesvc's TestMain regardless of any real leak (ga-gxmz9n).
+//
+// The fake ps here reports a single row for itself ($$, the real parent),
+// mirroring the one spurious row a genuine ps produces in the self-check
+// case; ChildPIDs must recognize that row as its own helper and exclude it.
+func TestChildPIDsExcludesItsOwnEnumerationHelper(t *testing.T) {
+ binDir := t.TempDir()
+ psPath := filepath.Join(binDir, "ps")
+ script := fmt.Sprintf("#!/bin/sh\necho \"$$ %d\"\n", os.Getpid())
+ if err := os.WriteFile(psPath, []byte(script), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ pids, err := ChildPIDs(os.Getpid())
+ if err != nil {
+ t.Fatalf("ChildPIDs(%d): %v", os.Getpid(), err)
+ }
+ if len(pids) != 0 {
+ t.Fatalf("ChildPIDs(%d) = %v, want empty — the only ps row was the enumeration helper's own (self, parent) pair and must be excluded, not reported as a leaked child", os.Getpid(), pids)
+ }
+}
+
func TestArgvHasFlagValue(t *testing.T) {
argv := []string{"gc", "nudge", "poll", "--city", "/tmp/city-a", "--session=s-worker"}
cases := []struct {
diff --git a/internal/pidutil/starttime_portable_test.go b/internal/pidutil/starttime_portable_test.go
new file mode 100644
index 0000000000..af201a7164
--- /dev/null
+++ b/internal/pidutil/starttime_portable_test.go
@@ -0,0 +1,113 @@
+package pidutil
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+// AliveWithStartTime closes the PID-reuse hole in Alive: during a post-SIGKILL
+// reap wait the target's PID can be recycled to an unrelated process, at which
+// point plain Alive wrongly reports the dead target as still alive.
+//
+// StartTime read only /proc//stat, so off Linux it always errored, the
+// identity check was skipped, and the hole stayed open. The visible consequence
+// is the opposite of the reaper's: killByPID reports
+// "PID %d still runnable %s after SIGKILL (not confirmed dead)" for a process
+// that is genuinely dead, and internal/runtime/subprocess and the tmux adapter
+// then refuse to start the replacement — an agent restart blocked by a
+// protection that cannot function.
+
+// TestStartTime_ReturnsValueOnThisHost is the regression test for the cause: a
+// start-time identity must be obtainable on the host the code runs on.
+func TestStartTime_ReturnsValueOnThisHost(t *testing.T) {
+ got, err := StartTime(os.Getpid())
+ if err != nil {
+ t.Fatalf("StartTime(self) on %s: %v", runtime.GOOS, err)
+ }
+ if strings.TrimSpace(got) == "" {
+ t.Fatalf("StartTime(self) on %s returned an empty identity", runtime.GOOS)
+ }
+}
+
+// TestAliveWithStartTime_RejectsMismatchedIdentity is the defect stated directly:
+// a live PID whose recorded start time does not match must be reported dead,
+// because that is what PID reuse looks like. Off Linux StartTime errored and the
+// function returned true, leaving the reuse hole open.
+func TestAliveWithStartTime_RejectsMismatchedIdentity(t *testing.T) {
+ if got := AliveWithStartTime(os.Getpid(), "definitely-not-this-processes-start-time"); got {
+ t.Fatalf("AliveWithStartTime(self, mismatched) = true on %s; a recycled PID would pass as the original process", runtime.GOOS)
+ }
+}
+
+// TestAliveWithStartTime_AcceptsSameProcess is the over-correction guard: the
+// real process must still be recognized. Passes before and after.
+func TestAliveWithStartTime_AcceptsSameProcess(t *testing.T) {
+ st, err := StartTime(os.Getpid())
+ if err != nil {
+ t.Fatalf("StartTime(self): %v", err)
+ }
+ if !AliveWithStartTime(os.Getpid(), st) {
+ t.Fatalf("AliveWithStartTime(self, own start time %q) = false", st)
+ }
+}
+
+// TestAliveWithStartTime_EmptyIdentityFallsBackToAlive pins the documented
+// opt-out: no captured identity means no identity check.
+func TestAliveWithStartTime_EmptyIdentityFallsBackToAlive(t *testing.T) {
+ if !AliveWithStartTime(os.Getpid(), "") {
+ t.Fatal("AliveWithStartTime(self, \"\") = false, want true (identity check disabled)")
+ }
+}
+
+// TestPSStartTimeReturnsIdentity covers the new fallback's success path.
+// ps -o lstart= works on linux too, so this runs on every platform — without
+// it, no CI job ever executes a successful psStartTime.
+func TestPSStartTimeReturnsIdentity(t *testing.T) {
+ got, err := psStartTime(os.Getpid())
+ if err != nil {
+ t.Fatalf("psStartTime(self) on %s: %v", runtime.GOOS, err)
+ }
+ if strings.TrimSpace(got) == "" {
+ t.Fatalf("psStartTime(self) on %s returned an empty identity", runtime.GOOS)
+ }
+}
+
+// TestAliveWithStartTime_UnreadableIdentityKeepsAliveAnswer pins the deliberately
+// CONSERVATIVE direction, which is the opposite of the reaper's. Here a missing
+// signal must not invent a death: reporting a live process dead would let a
+// caller start a second copy alongside it. So an unreadable identity keeps the
+// Alive answer, exactly as the pre-existing doc comment promises.
+func TestAliveWithStartTime_UnreadableIdentityKeepsAliveAnswer(t *testing.T) {
+ if runtime.GOOS == "linux" {
+ t.Skip("on linux /proc answers directly, so a ps stub cannot make the identity unreadable")
+ }
+ binDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(binDir, "ps"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ if !AliveWithStartTime(os.Getpid(), "some-captured-identity") {
+ t.Fatal("AliveWithStartTime = false when the identity is unreadable; a live process must not be reported dead")
+ }
+}
+
+// TestPSStartTimeIsBounded mirrors the other ps probes in this package: callers
+// sit in a post-SIGKILL reap loop, so a hung ps must not stall them.
+func TestPSStartTimeIsBounded(t *testing.T) {
+ binDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(binDir, "ps"), []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
+ t.Fatalf("WriteFile(ps): %v", err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, os.Getenv("PATH")}, string(os.PathListSeparator)))
+
+ start := time.Now()
+ _, _ = psStartTime(os.Getpid())
+ if elapsed := time.Since(start); elapsed > 2*time.Second {
+ t.Fatalf("psStartTime took %s, want a bounded timeout", elapsed)
+ }
+}
diff --git a/internal/productmetrics/command_ids_gen.go b/internal/productmetrics/command_ids_gen.go
index d7df68c69f..b178d16313 100644
--- a/internal/productmetrics/command_ids_gen.go
+++ b/internal/productmetrics/command_ids_gen.go
@@ -2,7 +2,7 @@
package productmetrics
-// command-census-ledger: {"next_id":200,"identities":[{"name":"agent-add","id":5,"wire":"agent-add","retired":false},{"name":"agent-list","id":6,"wire":"agent-list","retired":false},{"name":"agent-resume","id":7,"wire":"agent-resume","retired":false},{"name":"agent-suspend","id":8,"wire":"agent-suspend","retired":false},{"name":"agent-script","id":9,"wire":"agent-script","retired":false},{"name":"analyze-reliability","id":10,"wire":"analyze-reliability","retired":false},{"name":"bd","id":11,"wire":"bd","retired":false},{"name":"beads-city-use-external","id":12,"wire":"beads-city-use-external","retired":false},{"name":"beads-city-use-managed","id":13,"wire":"beads-city-use-managed","retired":false},{"name":"beads-health","id":14,"wire":"beads-health","retired":false},{"name":"beads-list","id":15,"wire":"beads-list","retired":false},{"name":"beads-show","id":16,"wire":"beads-show","retired":false},{"name":"build-image","id":17,"wire":"build-image","retired":false},{"name":"cities","id":18,"wire":"cities","retired":false},{"name":"cities-list","id":19,"wire":"cities-list","retired":false},{"name":"completion","id":20,"wire":"completion","retired":false},{"name":"config-explain","id":21,"wire":"config-explain","retired":false},{"name":"config-show","id":22,"wire":"config-show","retired":false},{"name":"converge-approve","id":23,"wire":"converge-approve","retired":false},{"name":"converge-create","id":24,"wire":"converge-create","retired":false},{"name":"converge-iterate","id":25,"wire":"converge-iterate","retired":false},{"name":"converge-list","id":26,"wire":"converge-list","retired":false},{"name":"converge-retry","id":27,"wire":"converge-retry","retired":false},{"name":"converge-status","id":28,"wire":"converge-status","retired":false},{"name":"converge-stop","id":29,"wire":"converge-stop","retired":false},{"name":"converge-test-gate","id":30,"wire":"converge-test-gate","retired":false},{"name":"converge-test-trigger","id":31,"wire":"converge-test-trigger","retired":false},{"name":"convoy-add","id":32,"wire":"convoy-add","retired":false},{"name":"convoy-check","id":33,"wire":"convoy-check","retired":false},{"name":"convoy-close","id":34,"wire":"convoy-close","retired":false},{"name":"convoy-control","id":35,"wire":"convoy-control","retired":false},{"name":"convoy-create","id":36,"wire":"convoy-create","retired":false},{"name":"convoy-delete","id":37,"wire":"convoy-delete","retired":false},{"name":"convoy-delete-source","id":38,"wire":"convoy-delete-source","retired":false},{"name":"convoy-land","id":39,"wire":"convoy-land","retired":false},{"name":"convoy-list","id":40,"wire":"convoy-list","retired":false},{"name":"convoy-reopen-source","id":41,"wire":"convoy-reopen-source","retired":false},{"name":"convoy-status","id":42,"wire":"convoy-status","retired":false},{"name":"convoy-stranded","id":43,"wire":"convoy-stranded","retired":false},{"name":"convoy-target","id":44,"wire":"convoy-target","retired":false},{"name":"costs","id":45,"wire":"costs","retired":false},{"name":"dashboard","id":46,"wire":"dashboard","retired":false},{"name":"dashboard-serve","id":47,"wire":"dashboard-serve","retired":false},{"name":"doctor","id":48,"wire":"doctor","retired":false},{"name":"dolt-cleanup","id":49,"wire":"dolt-cleanup","retired":false},{"name":"events","id":50,"wire":"events","retired":false},{"name":"events-rotate","id":51,"wire":"events-rotate","retired":false},{"name":"extmsg-bind","id":52,"wire":"extmsg-bind","retired":false},{"name":"extmsg-handoff","id":53,"wire":"extmsg-handoff","retired":false},{"name":"extmsg-unbind","id":54,"wire":"extmsg-unbind","retired":false},{"name":"formula-cook","id":55,"wire":"formula-cook","retired":false},{"name":"formula-list","id":56,"wire":"formula-list","retired":false},{"name":"formula-show","id":57,"wire":"formula-show","retired":false},{"name":"formula-version-check","id":58,"wire":"formula-version-check","retired":false},{"name":"github-pr-backfill","id":59,"wire":"github-pr-backfill","retired":false},{"name":"graph","id":60,"wire":"graph","retired":false},{"name":"handoff","id":61,"wire":"handoff","retired":false},{"name":"import-add","id":62,"wire":"import-add","retired":false},{"name":"import-check","id":63,"wire":"import-check","retired":false},{"name":"import-credential-add","id":64,"wire":"import-credential-add","retired":false},{"name":"import-credential-list","id":65,"wire":"import-credential-list","retired":false},{"name":"import-credential-remove","id":66,"wire":"import-credential-remove","retired":false},{"name":"import-install","id":67,"wire":"import-install","retired":false},{"name":"import-list","id":68,"wire":"import-list","retired":false},{"name":"import-prune","id":69,"wire":"import-prune","retired":false},{"name":"import-remove","id":70,"wire":"import-remove","retired":false},{"name":"import-status","id":71,"wire":"import-status","retired":false},{"name":"import-upgrade","id":72,"wire":"import-upgrade","retired":false},{"name":"import-why","id":73,"wire":"import-why","retired":false},{"name":"init","id":74,"wire":"init","retired":false},{"name":"lint","id":75,"wire":"lint","retired":false},{"name":"mail-archive","id":76,"wire":"mail-archive","retired":false},{"name":"mail-check","id":77,"wire":"mail-check","retired":false},{"name":"mail-count","id":78,"wire":"mail-count","retired":false},{"name":"mail-delete","id":79,"wire":"mail-delete","retired":false},{"name":"mail-inbox","id":80,"wire":"mail-inbox","retired":false},{"name":"mail-mark-read","id":81,"wire":"mail-mark-read","retired":false},{"name":"mail-mark-unread","id":82,"wire":"mail-mark-unread","retired":false},{"name":"mail-peek","id":83,"wire":"mail-peek","retired":false},{"name":"mail-read","id":84,"wire":"mail-read","retired":false},{"name":"mail-reply","id":85,"wire":"mail-reply","retired":false},{"name":"mail-send","id":86,"wire":"mail-send","retired":false},{"name":"mail-thread","id":87,"wire":"mail-thread","retired":false},{"name":"maintenance-dolt-gc","id":88,"wire":"maintenance-dolt-gc","retired":false},{"name":"maintenance-status","id":89,"wire":"maintenance-status","retired":false},{"name":"mcp-list","id":90,"wire":"mcp-list","retired":false},{"name":"nudge-status","id":91,"wire":"nudge-status","retired":false},{"name":"order-check","id":92,"wire":"order-check","retired":false},{"name":"order-history","id":93,"wire":"order-history","retired":false},{"name":"order-list","id":94,"wire":"order-list","retired":false},{"name":"order-run","id":95,"wire":"order-run","retired":false},{"name":"order-show","id":96,"wire":"order-show","retired":false},{"name":"order-sweep-nudge-mail","id":97,"wire":"order-sweep-nudge-mail","retired":false},{"name":"order-sweep-tracking","id":98,"wire":"order-sweep-tracking","retired":false},{"name":"pack-fetch","id":99,"wire":"pack-fetch","retired":false},{"name":"pack-list","id":100,"wire":"pack-list","retired":false},{"name":"pack-registry-add","id":101,"wire":"pack-registry-add","retired":false},{"name":"pack-registry-list","id":102,"wire":"pack-registry-list","retired":false},{"name":"pack-registry-login","id":103,"wire":"pack-registry-login","retired":false},{"name":"pack-registry-publish","id":104,"wire":"pack-registry-publish","retired":false},{"name":"pack-registry-refresh","id":105,"wire":"pack-registry-refresh","retired":false},{"name":"pack-registry-remove","id":106,"wire":"pack-registry-remove","retired":false},{"name":"pack-registry-search","id":107,"wire":"pack-registry-search","retired":false},{"name":"pack-registry-show","id":108,"wire":"pack-registry-show","retired":false},{"name":"pack-registry-whoami","id":109,"wire":"pack-registry-whoami","retired":false},{"name":"pack-release-hash","id":110,"wire":"pack-release-hash","retired":false},{"name":"pack-release-stamp","id":111,"wire":"pack-release-stamp","retired":false},{"name":"pack-release-validate","id":112,"wire":"pack-release-validate","retired":false},{"name":"pack-release-verify","id":113,"wire":"pack-release-verify","retired":false},{"name":"perf-run","id":114,"wire":"perf-run","retired":false},{"name":"perf-session-new","id":115,"wire":"perf-session-new","retired":false},{"name":"prime","id":116,"wire":"prime","retired":false},{"name":"prompt-synth","id":117,"wire":"prompt-synth","retired":false},{"name":"register","id":118,"wire":"register","retired":false},{"name":"reload","id":119,"wire":"reload","retired":false},{"name":"restart","id":120,"wire":"restart","retired":false},{"name":"resume","id":121,"wire":"resume","retired":false},{"name":"rig-add","id":122,"wire":"rig-add","retired":false},{"name":"rig-list","id":123,"wire":"rig-list","retired":false},{"name":"rig-remove","id":124,"wire":"rig-remove","retired":false},{"name":"rig-restart","id":125,"wire":"rig-restart","retired":false},{"name":"rig-resume","id":126,"wire":"rig-resume","retired":false},{"name":"rig-set-endpoint","id":127,"wire":"rig-set-endpoint","retired":false},{"name":"rig-status","id":128,"wire":"rig-status","retired":false},{"name":"rig-suspend","id":129,"wire":"rig-suspend","retired":false},{"name":"runtime-check","id":130,"wire":"runtime-check","retired":false},{"name":"runtime-conformance","id":131,"wire":"runtime-conformance","retired":false},{"name":"runtime-drain","id":132,"wire":"runtime-drain","retired":false},{"name":"runtime-drain-ack","id":133,"wire":"runtime-drain-ack","retired":false},{"name":"runtime-drain-check","id":134,"wire":"runtime-drain-check","retired":false},{"name":"runtime-request-restart","id":135,"wire":"runtime-request-restart","retired":false},{"name":"runtime-undrain","id":136,"wire":"runtime-undrain","retired":false},{"name":"service-doctor","id":137,"wire":"service-doctor","retired":false},{"name":"service-list","id":138,"wire":"service-list","retired":false},{"name":"service-restart","id":139,"wire":"service-restart","retired":false},{"name":"session-attach","id":140,"wire":"session-attach","retired":false},{"name":"session-close","id":141,"wire":"session-close","retired":false},{"name":"session-kill","id":142,"wire":"session-kill","retired":false},{"name":"session-list","id":143,"wire":"session-list","retired":false},{"name":"session-logs","id":144,"wire":"session-logs","retired":false},{"name":"session-new","id":145,"wire":"session-new","retired":false},{"name":"session-nudge","id":146,"wire":"session-nudge","retired":false},{"name":"session-peek","id":147,"wire":"session-peek","retired":false},{"name":"session-pin","id":148,"wire":"session-pin","retired":false},{"name":"session-prune","id":149,"wire":"session-prune","retired":false},{"name":"session-rename","id":150,"wire":"session-rename","retired":false},{"name":"session-reset","id":151,"wire":"session-reset","retired":false},{"name":"session-submit","id":152,"wire":"session-submit","retired":false},{"name":"session-suspend","id":153,"wire":"session-suspend","retired":false},{"name":"session-unpin","id":154,"wire":"session-unpin","retired":false},{"name":"session-wait","id":155,"wire":"session-wait","retired":false},{"name":"session-wake","id":156,"wire":"session-wake","retired":false},{"name":"shell-install","id":157,"wire":"shell-install","retired":false},{"name":"shell-remove","id":158,"wire":"shell-remove","retired":false},{"name":"shell-status","id":159,"wire":"shell-status","retired":false},{"name":"skill-list","id":160,"wire":"skill-list","retired":false},{"name":"sling","id":161,"wire":"sling","retired":false},{"name":"start","id":162,"wire":"start","retired":false},{"name":"status","id":163,"wire":"status","retired":false},{"name":"stop","id":164,"wire":"stop","retired":false},{"name":"supervisor-install","id":165,"wire":"supervisor-install","retired":false},{"name":"supervisor-logs","id":166,"wire":"supervisor-logs","retired":false},{"name":"supervisor-reload","id":167,"wire":"supervisor-reload","retired":false},{"name":"supervisor-run","id":168,"wire":"supervisor-run","retired":false},{"name":"supervisor-start","id":169,"wire":"supervisor-start","retired":false},{"name":"supervisor-status","id":170,"wire":"supervisor-status","retired":false},{"name":"supervisor-stop","id":171,"wire":"supervisor-stop","retired":false},{"name":"supervisor-uninstall","id":172,"wire":"supervisor-uninstall","retired":false},{"name":"suspend","id":173,"wire":"suspend","retired":false},{"name":"trace-cycle","id":174,"wire":"trace-cycle","retired":false},{"name":"trace-reasons","id":175,"wire":"trace-reasons","retired":false},{"name":"trace-show","id":176,"wire":"trace-show","retired":false},{"name":"trace-start","id":177,"wire":"trace-start","retired":false},{"name":"trace-status","id":178,"wire":"trace-status","retired":false},{"name":"trace-stop","id":179,"wire":"trace-stop","retired":false},{"name":"trace-tail","id":180,"wire":"trace-tail","retired":false},{"name":"unregister","id":181,"wire":"unregister","retired":false},{"name":"wait-cancel","id":182,"wire":"wait-cancel","retired":false},{"name":"wait-inspect","id":183,"wire":"wait-inspect","retired":false},{"name":"wait-list","id":184,"wire":"wait-list","retired":false},{"name":"wait-ready","id":185,"wire":"wait-ready","retired":false},{"name":"context-add","id":186,"wire":"context-add","retired":false},{"name":"context-current","id":187,"wire":"context-current","retired":false},{"name":"context-list","id":188,"wire":"context-list","retired":false},{"name":"context-remove","id":189,"wire":"context-remove","retired":false},{"name":"context-show","id":190,"wire":"context-show","retired":false},{"name":"context-use","id":191,"wire":"context-use","retired":false},{"name":"login","id":192,"wire":"login","retired":false},{"name":"logout","id":193,"wire":"logout","retired":false},{"name":"whoami","id":194,"wire":"whoami","retired":false},{"name":"runtime-heartbeat","id":195,"wire":"runtime-heartbeat","retired":false},{"name":"provider-rotate-key","id":196,"wire":"provider-rotate-key","retired":false},{"name":"beads-state","id":197,"wire":"beads-state","retired":false},{"name":"config-lint","id":198,"wire":"config-lint","retired":false},{"name":"provider-quota","id":199,"wire":"provider-quota","retired":false}]}
+// command-census-ledger: {"next_id":202,"identities":[{"name":"agent-add","id":5,"wire":"agent-add","retired":false},{"name":"agent-list","id":6,"wire":"agent-list","retired":false},{"name":"agent-resume","id":7,"wire":"agent-resume","retired":false},{"name":"agent-suspend","id":8,"wire":"agent-suspend","retired":false},{"name":"agent-script","id":9,"wire":"agent-script","retired":false},{"name":"analyze-reliability","id":10,"wire":"analyze-reliability","retired":false},{"name":"bd","id":11,"wire":"bd","retired":false},{"name":"beads-city-use-external","id":12,"wire":"beads-city-use-external","retired":false},{"name":"beads-city-use-managed","id":13,"wire":"beads-city-use-managed","retired":false},{"name":"beads-health","id":14,"wire":"beads-health","retired":false},{"name":"beads-list","id":15,"wire":"beads-list","retired":false},{"name":"beads-show","id":16,"wire":"beads-show","retired":false},{"name":"build-image","id":17,"wire":"build-image","retired":false},{"name":"cities","id":18,"wire":"cities","retired":false},{"name":"cities-list","id":19,"wire":"cities-list","retired":false},{"name":"completion","id":20,"wire":"completion","retired":false},{"name":"config-explain","id":21,"wire":"config-explain","retired":false},{"name":"config-show","id":22,"wire":"config-show","retired":false},{"name":"converge-approve","id":23,"wire":"converge-approve","retired":false},{"name":"converge-create","id":24,"wire":"converge-create","retired":false},{"name":"converge-iterate","id":25,"wire":"converge-iterate","retired":false},{"name":"converge-list","id":26,"wire":"converge-list","retired":false},{"name":"converge-retry","id":27,"wire":"converge-retry","retired":false},{"name":"converge-status","id":28,"wire":"converge-status","retired":false},{"name":"converge-stop","id":29,"wire":"converge-stop","retired":false},{"name":"converge-test-gate","id":30,"wire":"converge-test-gate","retired":false},{"name":"converge-test-trigger","id":31,"wire":"converge-test-trigger","retired":false},{"name":"convoy-add","id":32,"wire":"convoy-add","retired":false},{"name":"convoy-check","id":33,"wire":"convoy-check","retired":false},{"name":"convoy-close","id":34,"wire":"convoy-close","retired":false},{"name":"convoy-control","id":35,"wire":"convoy-control","retired":false},{"name":"convoy-create","id":36,"wire":"convoy-create","retired":false},{"name":"convoy-delete","id":37,"wire":"convoy-delete","retired":false},{"name":"convoy-delete-source","id":38,"wire":"convoy-delete-source","retired":false},{"name":"convoy-land","id":39,"wire":"convoy-land","retired":false},{"name":"convoy-list","id":40,"wire":"convoy-list","retired":false},{"name":"convoy-reopen-source","id":41,"wire":"convoy-reopen-source","retired":false},{"name":"convoy-status","id":42,"wire":"convoy-status","retired":false},{"name":"convoy-stranded","id":43,"wire":"convoy-stranded","retired":false},{"name":"convoy-target","id":44,"wire":"convoy-target","retired":false},{"name":"costs","id":45,"wire":"costs","retired":false},{"name":"dashboard","id":46,"wire":"dashboard","retired":false},{"name":"dashboard-serve","id":47,"wire":"dashboard-serve","retired":false},{"name":"doctor","id":48,"wire":"doctor","retired":false},{"name":"dolt-cleanup","id":49,"wire":"dolt-cleanup","retired":false},{"name":"events","id":50,"wire":"events","retired":false},{"name":"events-rotate","id":51,"wire":"events-rotate","retired":false},{"name":"extmsg-bind","id":52,"wire":"extmsg-bind","retired":false},{"name":"extmsg-handoff","id":53,"wire":"extmsg-handoff","retired":false},{"name":"extmsg-unbind","id":54,"wire":"extmsg-unbind","retired":false},{"name":"formula-cook","id":55,"wire":"formula-cook","retired":false},{"name":"formula-list","id":56,"wire":"formula-list","retired":false},{"name":"formula-show","id":57,"wire":"formula-show","retired":false},{"name":"formula-version-check","id":58,"wire":"formula-version-check","retired":false},{"name":"github-pr-backfill","id":59,"wire":"github-pr-backfill","retired":false},{"name":"graph","id":60,"wire":"graph","retired":false},{"name":"handoff","id":61,"wire":"handoff","retired":false},{"name":"import-add","id":62,"wire":"import-add","retired":false},{"name":"import-check","id":63,"wire":"import-check","retired":false},{"name":"import-credential-add","id":64,"wire":"import-credential-add","retired":false},{"name":"import-credential-list","id":65,"wire":"import-credential-list","retired":false},{"name":"import-credential-remove","id":66,"wire":"import-credential-remove","retired":false},{"name":"import-install","id":67,"wire":"import-install","retired":false},{"name":"import-list","id":68,"wire":"import-list","retired":false},{"name":"import-prune","id":69,"wire":"import-prune","retired":false},{"name":"import-remove","id":70,"wire":"import-remove","retired":false},{"name":"import-status","id":71,"wire":"import-status","retired":false},{"name":"import-upgrade","id":72,"wire":"import-upgrade","retired":false},{"name":"import-why","id":73,"wire":"import-why","retired":false},{"name":"init","id":74,"wire":"init","retired":false},{"name":"lint","id":75,"wire":"lint","retired":false},{"name":"mail-archive","id":76,"wire":"mail-archive","retired":false},{"name":"mail-check","id":77,"wire":"mail-check","retired":false},{"name":"mail-count","id":78,"wire":"mail-count","retired":false},{"name":"mail-delete","id":79,"wire":"mail-delete","retired":false},{"name":"mail-inbox","id":80,"wire":"mail-inbox","retired":false},{"name":"mail-mark-read","id":81,"wire":"mail-mark-read","retired":false},{"name":"mail-mark-unread","id":82,"wire":"mail-mark-unread","retired":false},{"name":"mail-peek","id":83,"wire":"mail-peek","retired":false},{"name":"mail-read","id":84,"wire":"mail-read","retired":false},{"name":"mail-reply","id":85,"wire":"mail-reply","retired":false},{"name":"mail-send","id":86,"wire":"mail-send","retired":false},{"name":"mail-thread","id":87,"wire":"mail-thread","retired":false},{"name":"maintenance-dolt-gc","id":88,"wire":"maintenance-dolt-gc","retired":false},{"name":"maintenance-status","id":89,"wire":"maintenance-status","retired":false},{"name":"mcp-list","id":90,"wire":"mcp-list","retired":false},{"name":"nudge-status","id":91,"wire":"nudge-status","retired":false},{"name":"order-check","id":92,"wire":"order-check","retired":false},{"name":"order-history","id":93,"wire":"order-history","retired":false},{"name":"order-list","id":94,"wire":"order-list","retired":false},{"name":"order-run","id":95,"wire":"order-run","retired":false},{"name":"order-show","id":96,"wire":"order-show","retired":false},{"name":"order-sweep-nudge-mail","id":97,"wire":"order-sweep-nudge-mail","retired":false},{"name":"order-sweep-tracking","id":98,"wire":"order-sweep-tracking","retired":false},{"name":"pack-fetch","id":99,"wire":"pack-fetch","retired":false},{"name":"pack-list","id":100,"wire":"pack-list","retired":false},{"name":"pack-registry-add","id":101,"wire":"pack-registry-add","retired":false},{"name":"pack-registry-list","id":102,"wire":"pack-registry-list","retired":false},{"name":"pack-registry-login","id":103,"wire":"pack-registry-login","retired":false},{"name":"pack-registry-publish","id":104,"wire":"pack-registry-publish","retired":false},{"name":"pack-registry-refresh","id":105,"wire":"pack-registry-refresh","retired":false},{"name":"pack-registry-remove","id":106,"wire":"pack-registry-remove","retired":false},{"name":"pack-registry-search","id":107,"wire":"pack-registry-search","retired":false},{"name":"pack-registry-show","id":108,"wire":"pack-registry-show","retired":false},{"name":"pack-registry-whoami","id":109,"wire":"pack-registry-whoami","retired":false},{"name":"pack-release-hash","id":110,"wire":"pack-release-hash","retired":false},{"name":"pack-release-stamp","id":111,"wire":"pack-release-stamp","retired":false},{"name":"pack-release-validate","id":112,"wire":"pack-release-validate","retired":false},{"name":"pack-release-verify","id":113,"wire":"pack-release-verify","retired":false},{"name":"perf-run","id":114,"wire":"perf-run","retired":false},{"name":"perf-session-new","id":115,"wire":"perf-session-new","retired":false},{"name":"prime","id":116,"wire":"prime","retired":false},{"name":"prompt-synth","id":117,"wire":"prompt-synth","retired":false},{"name":"register","id":118,"wire":"register","retired":false},{"name":"reload","id":119,"wire":"reload","retired":false},{"name":"restart","id":120,"wire":"restart","retired":false},{"name":"resume","id":121,"wire":"resume","retired":false},{"name":"rig-add","id":122,"wire":"rig-add","retired":false},{"name":"rig-list","id":123,"wire":"rig-list","retired":false},{"name":"rig-remove","id":124,"wire":"rig-remove","retired":false},{"name":"rig-restart","id":125,"wire":"rig-restart","retired":false},{"name":"rig-resume","id":126,"wire":"rig-resume","retired":false},{"name":"rig-set-endpoint","id":127,"wire":"rig-set-endpoint","retired":false},{"name":"rig-status","id":128,"wire":"rig-status","retired":false},{"name":"rig-suspend","id":129,"wire":"rig-suspend","retired":false},{"name":"runtime-check","id":130,"wire":"runtime-check","retired":false},{"name":"runtime-conformance","id":131,"wire":"runtime-conformance","retired":false},{"name":"runtime-drain","id":132,"wire":"runtime-drain","retired":false},{"name":"runtime-drain-ack","id":133,"wire":"runtime-drain-ack","retired":false},{"name":"runtime-drain-check","id":134,"wire":"runtime-drain-check","retired":false},{"name":"runtime-request-restart","id":135,"wire":"runtime-request-restart","retired":false},{"name":"runtime-undrain","id":136,"wire":"runtime-undrain","retired":false},{"name":"service-doctor","id":137,"wire":"service-doctor","retired":false},{"name":"service-list","id":138,"wire":"service-list","retired":false},{"name":"service-restart","id":139,"wire":"service-restart","retired":false},{"name":"session-attach","id":140,"wire":"session-attach","retired":false},{"name":"session-close","id":141,"wire":"session-close","retired":false},{"name":"session-kill","id":142,"wire":"session-kill","retired":false},{"name":"session-list","id":143,"wire":"session-list","retired":false},{"name":"session-logs","id":144,"wire":"session-logs","retired":false},{"name":"session-new","id":145,"wire":"session-new","retired":false},{"name":"session-nudge","id":146,"wire":"session-nudge","retired":false},{"name":"session-peek","id":147,"wire":"session-peek","retired":false},{"name":"session-pin","id":148,"wire":"session-pin","retired":false},{"name":"session-prune","id":149,"wire":"session-prune","retired":false},{"name":"session-rename","id":150,"wire":"session-rename","retired":false},{"name":"session-reset","id":151,"wire":"session-reset","retired":false},{"name":"session-submit","id":152,"wire":"session-submit","retired":false},{"name":"session-suspend","id":153,"wire":"session-suspend","retired":false},{"name":"session-unpin","id":154,"wire":"session-unpin","retired":false},{"name":"session-wait","id":155,"wire":"session-wait","retired":false},{"name":"session-wake","id":156,"wire":"session-wake","retired":false},{"name":"shell-install","id":157,"wire":"shell-install","retired":false},{"name":"shell-remove","id":158,"wire":"shell-remove","retired":false},{"name":"shell-status","id":159,"wire":"shell-status","retired":false},{"name":"skill-list","id":160,"wire":"skill-list","retired":false},{"name":"sling","id":161,"wire":"sling","retired":false},{"name":"start","id":162,"wire":"start","retired":false},{"name":"status","id":163,"wire":"status","retired":false},{"name":"stop","id":164,"wire":"stop","retired":false},{"name":"supervisor-install","id":165,"wire":"supervisor-install","retired":false},{"name":"supervisor-logs","id":166,"wire":"supervisor-logs","retired":false},{"name":"supervisor-reload","id":167,"wire":"supervisor-reload","retired":false},{"name":"supervisor-run","id":168,"wire":"supervisor-run","retired":false},{"name":"supervisor-start","id":169,"wire":"supervisor-start","retired":false},{"name":"supervisor-status","id":170,"wire":"supervisor-status","retired":false},{"name":"supervisor-stop","id":171,"wire":"supervisor-stop","retired":false},{"name":"supervisor-uninstall","id":172,"wire":"supervisor-uninstall","retired":false},{"name":"suspend","id":173,"wire":"suspend","retired":false},{"name":"trace-cycle","id":174,"wire":"trace-cycle","retired":false},{"name":"trace-reasons","id":175,"wire":"trace-reasons","retired":false},{"name":"trace-show","id":176,"wire":"trace-show","retired":false},{"name":"trace-start","id":177,"wire":"trace-start","retired":false},{"name":"trace-status","id":178,"wire":"trace-status","retired":false},{"name":"trace-stop","id":179,"wire":"trace-stop","retired":false},{"name":"trace-tail","id":180,"wire":"trace-tail","retired":false},{"name":"unregister","id":181,"wire":"unregister","retired":false},{"name":"wait-cancel","id":182,"wire":"wait-cancel","retired":false},{"name":"wait-inspect","id":183,"wire":"wait-inspect","retired":false},{"name":"wait-list","id":184,"wire":"wait-list","retired":false},{"name":"wait-ready","id":185,"wire":"wait-ready","retired":false},{"name":"context-add","id":186,"wire":"context-add","retired":false},{"name":"context-current","id":187,"wire":"context-current","retired":false},{"name":"context-list","id":188,"wire":"context-list","retired":false},{"name":"context-remove","id":189,"wire":"context-remove","retired":false},{"name":"context-show","id":190,"wire":"context-show","retired":false},{"name":"context-use","id":191,"wire":"context-use","retired":false},{"name":"login","id":192,"wire":"login","retired":false},{"name":"logout","id":193,"wire":"logout","retired":false},{"name":"whoami","id":194,"wire":"whoami","retired":false},{"name":"runtime-heartbeat","id":195,"wire":"runtime-heartbeat","retired":false},{"name":"pack-registry-requests","id":196,"wire":"pack-registry-requests","retired":false},{"name":"events-reemit-execution","id":197,"wire":"events-reemit-execution","retired":false},{"name":"beads-state","id":198,"wire":"beads-state","retired":false},{"name":"config-lint","id":199,"wire":"config-lint","retired":false},{"name":"provider-quota","id":200,"wire":"provider-quota","retired":false},{"name":"provider-rotate-key","id":201,"wire":"provider-rotate-key","retired":false}]}
const (
generatedCommandID5 CommandID = 5
@@ -200,6 +200,8 @@ const (
generatedCommandID197 CommandID = 197
generatedCommandID198 CommandID = 198
generatedCommandID199 CommandID = 199
+ generatedCommandID200 CommandID = 200
+ generatedCommandID201 CommandID = 201
)
func generatedCommandIDCatalog(yield func(commandIDEntry)) {
@@ -394,8 +396,10 @@ func generatedCommandIDCatalog(yield func(commandIDEntry)) {
yield(commandIDEntry{id: generatedCommandID193, wire: "logout"})
yield(commandIDEntry{id: generatedCommandID194, wire: "whoami"})
yield(commandIDEntry{id: generatedCommandID195, wire: "runtime-heartbeat"})
- yield(commandIDEntry{id: generatedCommandID196, wire: "provider-rotate-key"})
- yield(commandIDEntry{id: generatedCommandID197, wire: "beads-state"})
- yield(commandIDEntry{id: generatedCommandID198, wire: "config-lint"})
- yield(commandIDEntry{id: generatedCommandID199, wire: "provider-quota"})
+ yield(commandIDEntry{id: generatedCommandID196, wire: "pack-registry-requests"})
+ yield(commandIDEntry{id: generatedCommandID197, wire: "events-reemit-execution"})
+ yield(commandIDEntry{id: generatedCommandID198, wire: "beads-state"})
+ yield(commandIDEntry{id: generatedCommandID199, wire: "config-lint"})
+ yield(commandIDEntry{id: generatedCommandID200, wire: "provider-quota"})
+ yield(commandIDEntry{id: generatedCommandID201, wire: "provider-rotate-key"})
}
diff --git a/internal/productmetrics/event_test.go b/internal/productmetrics/event_test.go
index 0313cab1ec..2125a0ca59 100644
--- a/internal/productmetrics/event_test.go
+++ b/internal/productmetrics/event_test.go
@@ -350,12 +350,17 @@ func TestInjectedImmutableCommandCatalogRoundTripsWithoutExpandingProduction(t *
generatedCount := 0
generatedCommandIDCatalog(func(commandIDEntry) { generatedCount++ })
- // 191 upstream + 4 fork-only runnable commands (gc beads state, gc config
+ // 193 upstream + 4 fork-only runnable commands (gc beads state, gc config
// lint, gc provider quota, gc provider rotate-key). The fifth fork-only
// census path, "gc provider", is a command group and carries the shared
// group id rather than a catalog entry, so it does not count here.
- if generatedCount != 195 {
- t.Fatalf("generated production catalog has %d entries, want 195", generatedCount)
+ //
+ // Re-derived at the v1.4.0 resync: upstream grew 191 -> 193, and upstream
+ // also took ids 196/197, which the fork-only commands had held. Those four
+ // were reallocated to 198-201 (next_id 202) — a fork-local id remap only,
+ // since none of the four exists upstream.
+ if generatedCount != 197 {
+ t.Fatalf("generated production catalog has %d entries, want 197", generatedCount)
}
injected := func(yield func(commandIDEntry)) {
diff --git a/internal/resilience/breaker.go b/internal/resilience/breaker.go
index f0120fd68c..9025100628 100644
--- a/internal/resilience/breaker.go
+++ b/internal/resilience/breaker.go
@@ -133,7 +133,7 @@ type Breaker struct {
// now and jitter are injectable for deterministic tests.
now func() time.Time
- jitter func(capacity time.Duration) time.Duration
+ jitter func(capDur time.Duration) time.Duration
mu sync.Mutex
// onChange receives state transitions; guarded by mu so registry
@@ -163,13 +163,13 @@ func newBreaker(scope, opClass string, settings Settings, onChange func(Transiti
}
}
-// fullJitter draws a wait uniformly from (0, capacity]. Zero or negative caps
+// fullJitter draws a wait uniformly from (0, capDur]. Zero or negative caps
// return zero.
-func fullJitter(capacity time.Duration) time.Duration {
- if capacity <= 0 {
+func fullJitter(capDur time.Duration) time.Duration {
+ if capDur <= 0 {
return 0
}
- return time.Duration(rand.Int64N(int64(capacity))) + 1
+ return time.Duration(rand.Int64N(int64(capDur))) + 1
}
// Allow reports whether an operation may proceed. Closed: always true.
@@ -326,19 +326,19 @@ func (b *Breaker) openLocked(now time.Time) {
}
// backoffCapLocked returns min(OpenMax, OpenBase << (trips-1)) with
-// overflow protection. Caller must hold b.mu. The initial cap is OpenBase,
-// which withDefaults guarantees is ≤ OpenMax, and each doubling that reaches
-// or exceeds OpenMax returns OpenMax immediately — so the loop never exits
-// with cap > OpenMax and no post-loop clamp is needed.
+// overflow protection. Caller must hold b.mu.
func (b *Breaker) backoffCapLocked() time.Duration {
- capacity := b.settings.OpenBase
+ capDur := b.settings.OpenBase
for i := 1; i < b.trips; i++ {
- capacity *= 2
- if capacity >= b.settings.OpenMax || capacity <= 0 {
+ capDur *= 2
+ if capDur >= b.settings.OpenMax || capDur <= 0 {
return b.settings.OpenMax
}
}
- return capacity
+ if capDur > b.settings.OpenMax {
+ return b.settings.OpenMax
+ }
+ return capDur
}
// transitionLocked changes state and notifies the callback. Caller must
diff --git a/internal/resilience/breaker_test.go b/internal/resilience/breaker_test.go
index a063169c8b..d5b5c943f6 100644
--- a/internal/resilience/breaker_test.go
+++ b/internal/resilience/breaker_test.go
@@ -30,7 +30,7 @@ func (c *testClock) Advance(d time.Duration) {
// maxJitter pins full jitter to its upper bound so open deadlines are
// deterministic in tests.
-func maxJitter(capacity time.Duration) time.Duration { return capacity }
+func maxJitter(capDur time.Duration) time.Duration { return capDur }
func newTestBreaker(t *testing.T, settings Settings, clock *testClock, onChange func(Transition)) *Breaker {
t.Helper()
diff --git a/internal/runtime/REQUIREMENTS.md b/internal/runtime/REQUIREMENTS.md
index 1ef9c1fd33..a56a687420 100644
--- a/internal/runtime/REQUIREMENTS.md
+++ b/internal/runtime/REQUIREMENTS.md
@@ -114,6 +114,7 @@ differs, fix code and prove the row with a test.
| RUNTIME-CONTRACT-003 | Absent-session semantics | `Stop` is idempotent (nil for a missing session). `Nudge` returns nil only when best-effort no-op is safe; providers that can observe but not deliver return `runtime.ErrSessionNotFound` so callers do not mistake a no-op for delivery. | `internal/runtime/runtime.go` interface docs; `internal/runtime/runtimetest/conformance.go` |
| RUNTIME-CONTRACT-004 | Optional capabilities are interface extensions | Behavior beyond the core interface (dialog handling, idle-wait, activity reporting, ACP routing, …) is expressed as optional interfaces type-asserted by callers, never as flags on the core interface. | `internal/runtime/runtime.go`; `internal/runtime/dialog.go`; `cmd/gc/providers.go` (`registerStatusProviderACPRoutes`) |
| RUNTIME-CONTRACT-005 | Substrate conformance never implies worker-profile certification | Runtime conformance (`runtimetest`, `gc runtime check`) proves transport validity only. Tier-1 worker claims (`claude/tmux-cli`, …) live in the worker conformance catalog (`internal/worker/workertest`, WC-*/WI-* rows) and are explicit certification decisions per profile — a new runtime never auto-certifies derived profiles. The seam is WC-TRANSPORT-001, whose real-transport proof constructs providers through the runtime registry. | `internal/worker/workertest/catalog.go`; `cmd/gc/phase2_real_transport_test.go`; `engdocs/design/worker-conformance.md` |
+| RUNTIME-CONTRACT-006 | T3 listing fails closed on total observation failure | When the T3 bridge snapshot is transiently unavailable or still initializing, `ListRunning` returns no names with an error wrapping `ErrRuntimeUnavailable`; it never returns authoritative empty success. Absence-consuming callers therefore defer until the bridge can provide a complete snapshot. | `internal/runtime/t3bridge/provider.go`; `internal/runtime/t3bridge/provider_test.go` `TestListRunningSoftUnavailableIsRuntimeUnavailable` |
### RPP v0 (Exec Protocol)
diff --git a/internal/runtime/acp/acp.go b/internal/runtime/acp/acp.go
index 6c0795863b..f920fccfc5 100644
--- a/internal/runtime/acp/acp.go
+++ b/internal/runtime/acp/acp.go
@@ -19,6 +19,7 @@ import (
"syscall"
"time"
+ "github.com/gastownhall/gascity/internal/fsys"
"github.com/gastownhall/gascity/internal/runtime"
)
@@ -58,11 +59,12 @@ func (c *Config) outputBufferLines() int {
// Provider manages agent sessions using the Agent Client Protocol.
type Provider struct {
- mu sync.Mutex
- dir string // socket/meta file directory
- conns map[string]*sessionConn // in-process tracking
- workDirs map[string]string // session name → workDir (for CopyTo)
- cfg Config
+ mu sync.Mutex
+ dir string // socket/meta file directory
+ conns map[string]*sessionConn // in-process tracking
+ workDirs map[string]string // session name → workDir (for CopyTo)
+ cfg Config
+ activityWrite func(path string, data []byte) error // test seam
}
// Compile-time check.
@@ -256,7 +258,14 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
// IsRunning falls through to socketAlive and returns true.
go func() {
_ = cmd.Wait()
+ // Order the read loop's exit ahead of the publisher's final flush so a
+ // session/update the loop did dispatch cannot race publication
+ // shutdown. This is ordering, not a drain guarantee: cmd.Wait closes
+ // the stdout read end itself, so bytes still unread at that point are
+ // not guaranteed to be dispatched.
+ <-sc.readDone
sc.drainPending()
+ sc.closeActivityPublisher()
lis.Close() //nolint:errcheck
os.Remove(p.sockPath(name)) //nolint:errcheck
_ = os.Remove(p.sockNamePath(name))
@@ -295,7 +304,61 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
return fmt.Errorf("session %q was stopped during startup", name)
}
+ // Seed the sidecar synchronously at handshake completion. Start must not
+ // advertise a cross-process activity-capable session until the first
+ // durable value exists. Later updates use the non-blocking publisher.
+ seed := time.Now()
+ if err := p.publishActivity(name, seed); err != nil {
+ _ = stdinPipe.Close()
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ <-sc.done
+ p.mu.Lock()
+ if p.conns[name] == sentinel {
+ delete(p.conns, name)
+ delete(p.workDirs, name)
+ p.cleanupMeta(name)
+ }
+ p.mu.Unlock()
+ return fmt.Errorf("publishing initial activity for %q: %w", name, err)
+ }
+ publisher := newActivityPublisher(
+ activityPublishInterval,
+ time.Now(),
+ func(stamp time.Time) error { return p.publishActivity(name, stamp) },
+ func(err error) {
+ fmt.Fprintf(os.Stderr, "acp: publishing activity for %q: %v\n", name, err)
+ },
+ )
+ if err := sc.installActivityPublisher(publisher, seed); err != nil {
+ _ = stdinPipe.Close()
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ <-sc.done
+ p.mu.Lock()
+ if p.conns[name] == sentinel {
+ delete(p.conns, name)
+ delete(p.workDirs, name)
+ p.cleanupMeta(name)
+ }
+ p.mu.Unlock()
+ return fmt.Errorf("starting activity publication for %q: %w", name, err)
+ }
+
+ // Commit the real connection only if the startup sentinel still owns the
+ // name. Stop may have removed it while the initial atomic write was in
+ // progress.
p.mu.Lock()
+ if p.conns[name] != sentinel {
+ p.mu.Unlock()
+ _ = stdinPipe.Close()
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ <-sc.done
+ p.mu.Lock()
+ if _, replaced := p.conns[name]; !replaced {
+ p.cleanupMeta(name)
+ }
+ p.mu.Unlock()
+ return fmt.Errorf("session %q was stopped during startup", name)
+ }
p.conns[name] = sc
p.mu.Unlock()
@@ -608,15 +671,70 @@ func (p *Provider) RemoveMeta(name, key string) error {
return err
}
-// GetLastActivity returns the time of the last session/update notification.
+// lastActivityMetaKey names the sidecar holding the durable last-activity
+// stamp. Keeping it in the meta namespace means Stop's cleanupMeta already
+// removes it along with the rest of the session's sidecar state.
+const lastActivityMetaKey = "gc_last_activity"
+
+// publishActivity atomically replaces the durable last-activity stamp. Atomic
+// replacement prevents cross-process readers from observing a truncated or
+// partially-written timestamp.
+func (p *Provider) publishActivity(name string, t time.Time) error {
+ path := p.metaPath(name, lastActivityMetaKey)
+ data := []byte(t.UTC().Format(time.RFC3339Nano))
+ var err error
+ if p.activityWrite != nil {
+ err = p.activityWrite(path, data)
+ } else {
+ err = fsys.WriteFileAtomic(fsys.OSFS{}, path, data, 0o644)
+ }
+ if err != nil {
+ return fmt.Errorf("writing activity sidecar: %w", err)
+ }
+ return nil
+}
+
+// GetLastActivity returns the time of the last observed session/update, or the
+// Start-time seed if none has been observed.
+//
+// It reads the in-process connection when this process owns it, and otherwise
+// falls back to the durable stamp on disk — the same
+// in-memory-then-cross-process shape that Stop, Interrupt and IsRunning
+// already use for the control socket.
+//
+// The connection and in-memory stamp live only in the process that ran Start.
+// The sidecar gives other processes the same last-observed protocol timestamp.
func (p *Provider) GetLastActivity(name string) (time.Time, error) {
p.mu.Lock()
sc, ok := p.conns[name]
p.mu.Unlock()
- if !ok {
+ if ok {
+ if t := sc.getLastActivity(); !t.IsZero() {
+ return t, nil
+ }
+ }
+ return p.persistedActivity(name)
+}
+
+// persistedActivity reads the durable last-activity stamp.
+//
+// A missing stamp is "unknown" (zero, nil) — the pre-existing contract for a
+// session this provider knows nothing about. An unreadable or malformed stamp
+// is an error rather than a silent zero.
+func (p *Provider) persistedActivity(name string) (time.Time, error) {
+ raw, err := p.GetMeta(name, lastActivityMetaKey)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("reading last activity for %q: %w", name, err)
+ }
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
return time.Time{}, nil
}
- return sc.getLastActivity(), nil
+ t, err := time.Parse(time.RFC3339Nano, raw)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("parsing last activity for %q: %w", name, err)
+ }
+ return t, nil
}
// ClearScrollback clears the output buffer.
@@ -852,10 +970,16 @@ func isUnavailableSocketError(err error) bool {
errors.Is(err, syscall.ECONNREFUSED)
}
-// Capabilities reports ACP provider capabilities. The ACP provider has
-// no terminal and does not natively support attachment or activity detection.
+// Capabilities reports ACP provider capabilities. ACP sessions are headless,
+// so attachment is never reportable — but session/update notifications are a
+// real activity signal, durably stamped by GetLastActivity's sidecar so it
+// survives the process boundary.
+//
+// Declaring the capability allows activity-aware policies to use the signal.
+// Those policies remain independently configured; activity age alone does not
+// diagnose the reason updates stopped.
func (p *Provider) Capabilities() runtime.ProviderCapabilities {
- return runtime.ProviderCapabilities{}
+ return runtime.ProviderCapabilities{CanReportActivity: true}
}
// SleepCapability reports that ACP sessions support timed-only idle sleep.
diff --git a/internal/runtime/acp/activity_publisher.go b/internal/runtime/acp/activity_publisher.go
new file mode 100644
index 0000000000..15a49749b6
--- /dev/null
+++ b/internal/runtime/acp/activity_publisher.go
@@ -0,0 +1,195 @@
+package acp
+
+import (
+ "sync"
+ "time"
+)
+
+// activityPublishInterval bounds durable activity-stamp write amplification.
+// Activity remains exact in memory; the cross-process sidecar trails by at
+// most this interval while updates continue.
+const activityPublishInterval = 5 * time.Second
+
+// activityPublishRetryInterval keeps a transient sidecar failure from
+// suppressing publication for a full activity interval.
+const activityPublishRetryInterval = time.Second
+
+// activityPublisher serializes, coalesces, and throttles durable activity
+// writes. offer never performs I/O and never waits for the worker.
+type activityPublisher struct {
+ interval time.Duration
+ publish func(time.Time) error
+ onError func(error)
+
+ mu sync.Mutex
+ latest time.Time
+ pending bool
+ stopped bool
+
+ wake chan struct{}
+ stop chan struct{}
+ done chan struct{}
+ stopOnce sync.Once
+}
+
+func newActivityPublisher(
+ interval time.Duration,
+ lastWrite time.Time,
+ publish func(time.Time) error,
+ onError func(error),
+) *activityPublisher {
+ if interval <= 0 {
+ interval = activityPublishInterval
+ }
+ ap := &activityPublisher{
+ interval: interval,
+ publish: publish,
+ onError: onError,
+ wake: make(chan struct{}, 1),
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+ go ap.run(lastWrite)
+ return ap
+}
+
+// offer records the newest observed timestamp and wakes the publisher without
+// waiting for filesystem I/O. Older timestamps are ignored so the durable
+// value cannot move backwards even if callers race.
+func (ap *activityPublisher) offer(stamp time.Time) {
+ ap.mu.Lock()
+ if ap.stopped || (!ap.latest.IsZero() && !stamp.After(ap.latest)) {
+ ap.mu.Unlock()
+ return
+ }
+ ap.latest = stamp
+ ap.pending = true
+ ap.mu.Unlock()
+
+ select {
+ case ap.wake <- struct{}{}:
+ default:
+ }
+}
+
+// close stops the worker and waits until no publication can still be in
+// flight. Stop uses this before removing sidecars, preventing a late write
+// from recreating activity metadata for a removed session.
+func (ap *activityPublisher) close() {
+ ap.stopOnce.Do(func() {
+ ap.mu.Lock()
+ ap.stopped = true
+ ap.mu.Unlock()
+ close(ap.stop)
+ })
+ <-ap.done
+}
+
+func (ap *activityPublisher) run(lastWrite time.Time) {
+ defer close(ap.done)
+
+ retrying := false
+ var retryAt time.Time
+ reportedFailure := false
+ for {
+ _, ok := ap.pendingStamp()
+ if !ok {
+ select {
+ case <-ap.wake:
+ continue
+ case <-ap.stop:
+ ap.flushOnStop(reportedFailure)
+ return
+ }
+ }
+
+ delay := time.Until(lastWrite.Add(ap.interval))
+ if retrying {
+ delay = time.Until(retryAt)
+ }
+ if delay > 0 {
+ timer := time.NewTimer(delay)
+ select {
+ case <-timer.C:
+ case <-ap.wake:
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ continue
+ case <-ap.stop:
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ ap.flushOnStop(reportedFailure)
+ return
+ }
+ }
+
+ // Re-snapshot after the throttle wait so a burst becomes one write of
+ // the newest timestamp rather than one write of the first timestamp.
+ stamp, ok := ap.pendingStamp()
+ if !ok {
+ continue
+ }
+ if err := ap.publish(stamp); err != nil {
+ if !reportedFailure && ap.onError != nil {
+ ap.onError(err)
+ reportedFailure = true
+ }
+ retrying = true
+ retryDelay := activityPublishRetryInterval
+ if ap.interval < retryDelay {
+ retryDelay = ap.interval
+ }
+ retryAt = time.Now().Add(retryDelay)
+ continue
+ }
+
+ lastWrite = time.Now()
+ retrying = false
+ reportedFailure = false
+ ap.markPublished(stamp)
+ }
+}
+
+// flushOnStop makes one final best-effort attempt for a coalesced update that
+// was still inside the throttle or retry window. close waits for this attempt,
+// so no write can recreate metadata after lifecycle cleanup proceeds.
+func (ap *activityPublisher) flushOnStop(failureAlreadyReported bool) {
+ ap.mu.Lock()
+ stamp, pending := ap.latest, ap.pending
+ ap.mu.Unlock()
+ if !pending {
+ return
+ }
+ if err := ap.publish(stamp); err != nil {
+ if !failureAlreadyReported && ap.onError != nil {
+ ap.onError(err)
+ }
+ return
+ }
+ ap.markPublished(stamp)
+}
+
+func (ap *activityPublisher) pendingStamp() (time.Time, bool) {
+ ap.mu.Lock()
+ defer ap.mu.Unlock()
+ if ap.stopped {
+ return time.Time{}, false
+ }
+ return ap.latest, ap.pending
+}
+
+func (ap *activityPublisher) markPublished(stamp time.Time) {
+ ap.mu.Lock()
+ if !ap.latest.After(stamp) {
+ ap.pending = false
+ }
+ ap.mu.Unlock()
+}
diff --git a/internal/runtime/acp/activity_test.go b/internal/runtime/acp/activity_test.go
new file mode 100644
index 0000000000..4c3e4b7de1
--- /dev/null
+++ b/internal/runtime/acp/activity_test.go
@@ -0,0 +1,446 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+)
+
+// updateNotification builds a session/update notification carrying one agent
+// message chunk.
+func updateNotification(t *testing.T, text string) JSONRPCMessage {
+ t.Helper()
+ content, err := json.Marshal(ContentBlock{Type: "text", Text: text})
+ if err != nil {
+ t.Fatalf("marshal content block: %v", err)
+ }
+ params, err := json.Marshal(SessionUpdateParams{
+ Update: SessionUpdateContent{Type: "agent_message_chunk", Content: content},
+ })
+ if err != nil {
+ t.Fatalf("marshal update params: %v", err)
+ }
+ return JSONRPCMessage{JSONRPC: "2.0", Method: "session/update", Params: params}
+}
+
+func waitForActivityTest(t *testing.T, ch <-chan struct{}, what string) {
+ t.Helper()
+ timer := time.NewTimer(2 * time.Second)
+ defer timer.Stop()
+ select {
+ case <-ch:
+ case <-timer.C:
+ t.Fatalf("timed out waiting for %s", what)
+ }
+}
+
+func TestGetLastActivityIsReadableFromAnotherProvider(t *testing.T) {
+ dir := filepath.Join(shortTempDir(t), "acp")
+ owner := NewProviderWithDir(dir, Config{})
+ name := testName()
+
+ stamp := time.Now().Add(-42 * time.Minute).UTC().Truncate(time.Millisecond)
+ if err := owner.publishActivity(name, stamp); err != nil {
+ t.Fatalf("publishActivity: %v", err)
+ }
+
+ // A second Provider over the same directory models any process that did
+ // not start and therefore does not own the in-memory connection.
+ reader := NewProviderWithDir(dir, Config{})
+ got, err := reader.GetLastActivity(name)
+ if err != nil {
+ t.Fatalf("GetLastActivity: %v", err)
+ }
+ if !got.Equal(stamp) {
+ t.Fatalf("GetLastActivity = %s, want %s", got.Format(time.RFC3339Nano), stamp.Format(time.RFC3339Nano))
+ }
+}
+
+func TestActivityPublicationDoesNotBlockJSONRPCDispatch(t *testing.T) {
+ writeStarted := make(chan struct{})
+ releaseWrite := make(chan struct{})
+ publisher := newActivityPublisher(
+ time.Millisecond,
+ time.Time{},
+ func(time.Time) error {
+ close(writeStarted)
+ <-releaseWrite
+ return nil
+ },
+ nil,
+ )
+ t.Cleanup(func() {
+ select {
+ case <-releaseWrite:
+ default:
+ close(releaseWrite)
+ }
+ publisher.close()
+ })
+
+ sc := newSessionConn(nil, nil, nil, 100, nil)
+ if err := sc.installActivityPublisher(publisher, time.Time{}); err != nil {
+ t.Fatalf("installActivityPublisher: %v", err)
+ }
+ sc.handleUpdate(updateNotification(t, "streaming"))
+ waitForActivityTest(t, writeStarted, "blocked durable write")
+
+ id := int64(17)
+ response := make(chan JSONRPCMessage, 1)
+ sc.mu.Lock()
+ sc.pending[id] = response
+ sc.mu.Unlock()
+
+ dispatched := make(chan struct{})
+ go func() {
+ sc.dispatch(JSONRPCMessage{JSONRPC: "2.0", ID: &id})
+ close(dispatched)
+ }()
+ waitForActivityTest(t, dispatched, "JSON-RPC response dispatch")
+ select {
+ case <-response:
+ default:
+ t.Fatal("response was not routed while activity write was blocked")
+ }
+ close(releaseWrite)
+}
+
+func TestActivityPublisherSerializesCoalescesAndOrdersWrites(t *testing.T) {
+ firstStarted := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ twoWrites := make(chan struct{})
+
+ var (
+ mu sync.Mutex
+ writes []time.Time
+ )
+ publisher := newActivityPublisher(
+ time.Millisecond,
+ time.Time{},
+ func(stamp time.Time) error {
+ mu.Lock()
+ writes = append(writes, stamp)
+ count := len(writes)
+ mu.Unlock()
+ if count == 1 {
+ close(firstStarted)
+ <-releaseFirst
+ }
+ if count == 2 {
+ close(twoWrites)
+ }
+ return nil
+ },
+ nil,
+ )
+ t.Cleanup(publisher.close)
+
+ base := time.Now()
+ publisher.offer(base)
+ waitForActivityTest(t, firstStarted, "first write")
+ publisher.offer(base.Add(time.Second))
+ publisher.offer(base.Add(2 * time.Second))
+ close(releaseFirst)
+ waitForActivityTest(t, twoWrites, "coalesced trailing write")
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(writes) != 2 {
+ t.Fatalf("writes = %v, want exactly two", writes)
+ }
+ if !writes[0].Equal(base) || !writes[1].Equal(base.Add(2*time.Second)) {
+ t.Fatalf("writes = %v, want [%s %s]", writes, base, base.Add(2*time.Second))
+ }
+}
+
+func TestActivityPublisherRetriesAndReportsFailure(t *testing.T) {
+ succeeded := make(chan struct{})
+ var attempts atomic.Int32
+ var reports atomic.Int32
+ publisher := newActivityPublisher(
+ time.Millisecond,
+ time.Time{},
+ func(time.Time) error {
+ switch attempts.Add(1) {
+ case 1, 2:
+ return errors.New("injected sidecar failure")
+ default:
+ close(succeeded)
+ return nil
+ }
+ },
+ func(error) { reports.Add(1) },
+ )
+ t.Cleanup(publisher.close)
+
+ publisher.offer(time.Now())
+ waitForActivityTest(t, succeeded, "activity publication retry")
+ if got := attempts.Load(); got != 3 {
+ t.Fatalf("attempts = %d, want 3", got)
+ }
+ if got := reports.Load(); got != 1 {
+ t.Fatalf("error reports = %d, want one report for the failure streak", got)
+ }
+}
+
+func TestActivityPublisherUpdatesDoNotPostponeRetry(t *testing.T) {
+ succeeded := make(chan struct{})
+ var attempts atomic.Int32
+ publisher := newActivityPublisher(
+ 10*time.Millisecond,
+ time.Time{},
+ func(time.Time) error {
+ switch attempts.Add(1) {
+ case 1:
+ return errors.New("injected sidecar failure")
+ case 2:
+ close(succeeded)
+ }
+ return nil
+ },
+ nil,
+ )
+ t.Cleanup(publisher.close)
+
+ base := time.Now()
+ publisher.offer(base)
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(time.Millisecond)
+ defer ticker.Stop()
+ for i := 1; ; i++ {
+ select {
+ case <-succeeded:
+ if got := attempts.Load(); got != 2 {
+ t.Fatalf("attempts = %d, want 2", got)
+ }
+ return
+ case <-ticker.C:
+ publisher.offer(base.Add(time.Duration(i) * time.Millisecond))
+ case <-deadline.C:
+ t.Fatal("continuous updates postponed activity publication retry")
+ }
+ }
+}
+
+func TestActivityPublisherCloseFlushesPendingUpdate(t *testing.T) {
+ var (
+ mu sync.Mutex
+ writes []time.Time
+ )
+ publisher := newActivityPublisher(
+ time.Hour,
+ time.Now(),
+ func(stamp time.Time) error {
+ mu.Lock()
+ writes = append(writes, stamp)
+ mu.Unlock()
+ return nil
+ },
+ nil,
+ )
+ stamp := time.Now().Add(time.Second)
+ publisher.offer(stamp)
+ publisher.close()
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(writes) != 1 || !writes[0].Equal(stamp) {
+ t.Fatalf("writes on close = %v, want [%s]", writes, stamp)
+ }
+}
+
+func TestReadLoopDoneIncludesFinalActivityUpdate(t *testing.T) {
+ var published time.Time
+ publisher := newActivityPublisher(
+ time.Hour,
+ time.Now(),
+ func(stamp time.Time) error {
+ published = stamp
+ return nil
+ },
+ nil,
+ )
+ sc := newSessionConn(nil, nil, nil, 100, nil)
+ if err := sc.installActivityPublisher(publisher, time.Time{}); err != nil {
+ t.Fatalf("installActivityPublisher: %v", err)
+ }
+
+ reader, writer := io.Pipe()
+ go sc.readLoop(reader)
+ encoded, err := json.Marshal(updateNotification(t, "final buffered update"))
+ if err != nil {
+ t.Fatalf("marshal update: %v", err)
+ }
+ if _, err := writer.Write(append(encoded, '\n')); err != nil {
+ t.Fatalf("write update: %v", err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatalf("close update writer: %v", err)
+ }
+ waitForActivityTest(t, sc.readDone, "read loop completion")
+ want := sc.getLastActivity()
+ publisher.close()
+
+ if want.IsZero() {
+ t.Fatal("final buffered update did not advance in-memory activity")
+ }
+ if !published.Equal(want) {
+ t.Fatalf("published on close = %s, want final activity %s", published, want)
+ }
+}
+
+func TestPublishActivityIsAtomicForConcurrentReaders(t *testing.T) {
+ dir := filepath.Join(shortTempDir(t), "acp")
+ writer := NewProviderWithDir(dir, Config{})
+ reader := NewProviderWithDir(dir, Config{})
+ name := testName()
+ first := time.Unix(1_700_000_000, 123).UTC()
+ second := time.Unix(1_800_000_000, 456).UTC()
+ if err := writer.publishActivity(name, first); err != nil {
+ t.Fatalf("initial publishActivity: %v", err)
+ }
+
+ writerDone := make(chan struct{})
+ go func() {
+ defer close(writerDone)
+ for i := range 200 {
+ stamp := first
+ if i%2 == 1 {
+ stamp = second
+ }
+ if err := writer.publishActivity(name, stamp); err != nil {
+ t.Errorf("publishActivity: %v", err)
+ return
+ }
+ }
+ }()
+
+ for {
+ got, err := reader.GetLastActivity(name)
+ if err != nil {
+ t.Fatalf("GetLastActivity observed a partial sidecar: %v", err)
+ }
+ if !got.Equal(first) && !got.Equal(second) {
+ t.Fatalf("GetLastActivity = %s, want one complete published value", got)
+ }
+ select {
+ case <-writerDone:
+ return
+ default:
+ }
+ }
+}
+
+func TestPersistedActivityRejectsCorruptStamp(t *testing.T) {
+ dir := filepath.Join(shortTempDir(t), "acp")
+ p := NewProviderWithDir(dir, Config{})
+ name := testName()
+
+ if err := p.SetMeta(name, lastActivityMetaKey, "not-a-timestamp"); err != nil {
+ t.Fatalf("SetMeta: %v", err)
+ }
+ if _, err := p.GetLastActivity(name); err == nil {
+ t.Fatal("GetLastActivity accepted a corrupt stamp")
+ }
+}
+
+func TestGetLastActivityUnknownSessionIsZero(t *testing.T) {
+ p := NewProviderWithDir(filepath.Join(shortTempDir(t), "acp"), Config{})
+ got, err := p.GetLastActivity("never-started")
+ if err != nil {
+ t.Fatalf("GetLastActivity: %v", err)
+ }
+ if !got.IsZero() {
+ t.Fatalf("GetLastActivity = %s, want zero for an unknown session", got)
+ }
+}
+
+func TestCapabilitiesDeclareActivity(t *testing.T) {
+ caps := newTestProvider(t).Capabilities()
+ if !caps.CanReportActivity {
+ t.Fatal("CanReportActivity = false")
+ }
+ if caps.CanReportAttachment {
+ t.Fatal("CanReportAttachment = true; ACP sessions are headless")
+ }
+}
+
+func TestStartSeedsDurableActivity(t *testing.T) {
+ p := newTestProvider(t)
+ name := testName()
+
+ before := time.Now()
+ if err := p.Start(context.Background(), name, runtime.Config{
+ Command: fakeACPShellCommand(),
+ WorkDir: t.TempDir(),
+ }); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ t.Cleanup(func() { _ = p.Stop(name) })
+
+ reader := NewProviderWithDir(p.dir, Config{})
+ got, err := reader.GetLastActivity(name)
+ if err != nil {
+ t.Fatalf("GetLastActivity: %v", err)
+ }
+ if got.IsZero() || got.Before(before.Add(-time.Second)) {
+ t.Fatalf("seeded activity = %s, want a durable Start-time value", got)
+ }
+}
+
+func TestStartFailsWhenInitialActivityCannotBePublished(t *testing.T) {
+ p := newTestProvider(t)
+ p.activityWrite = func(string, []byte) error {
+ return errors.New("injected write failure")
+ }
+ name := testName()
+
+ err := p.Start(context.Background(), name, runtime.Config{
+ Command: fakeACPShellCommand(),
+ WorkDir: t.TempDir(),
+ })
+ if err == nil || !strings.Contains(err.Error(), "publishing initial activity") {
+ t.Fatalf("Start error = %v, want initial activity publication error", err)
+ }
+ if p.IsRunning(name) {
+ t.Fatalf("session %q remained running after initial activity publication failed", name)
+ }
+}
+
+func TestStopClearsDurableActivity(t *testing.T) {
+ p := newTestProvider(t)
+ name := testName()
+
+ if err := p.Start(context.Background(), name, runtime.Config{
+ Command: fakeACPShellCommand(),
+ WorkDir: t.TempDir(),
+ }); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ if err := p.Stop(name); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+
+ if _, err := os.Stat(p.metaPath(name, lastActivityMetaKey)); !os.IsNotExist(err) {
+ t.Fatalf("activity stamp survived Stop: stat err = %v", err)
+ }
+ reader := NewProviderWithDir(p.dir, Config{})
+ got, err := reader.GetLastActivity(name)
+ if err != nil {
+ t.Fatalf("GetLastActivity: %v", err)
+ }
+ if !got.IsZero() {
+ t.Fatalf("GetLastActivity = %s after Stop, want zero", got)
+ }
+}
diff --git a/internal/runtime/acp/conn.go b/internal/runtime/acp/conn.go
index a44178432c..371c5ed4e8 100644
--- a/internal/runtime/acp/conn.go
+++ b/internal/runtime/acp/conn.go
@@ -22,6 +22,7 @@ type sessionConn struct {
cmd *exec.Cmd
stdin io.WriteCloser
done chan struct{} // closed when process exits
+ readDone chan struct{} // closed after buffered stdout is dispatched
cancel context.CancelFunc // cancels in-progress handshake (sentinel only, set by Start)
listener net.Listener // control socket for cross-process ops
@@ -32,6 +33,12 @@ type sessionConn struct {
outputBufMax int
lastActivity time.Time
+ // activityPublisher moves sidecar I/O off the JSON-RPC read loop. It is
+ // installed after the handshake seed is durably committed and detached
+ // before session metadata is removed.
+ activityPublisher *activityPublisher
+ activityPublisherClosed bool
+
// stdinMu serializes writes to the agent's stdin pipe. Separate from
// mu so that a slow/blocked stdin write cannot prevent dispatch (which
// needs mu) from routing responses, avoiding a circular pipe deadlock.
@@ -58,6 +65,7 @@ func newSessionConn(cmd *exec.Cmd, stdin io.WriteCloser, lis net.Listener, bufSi
cmd: cmd,
stdin: stdin,
done: done,
+ readDone: make(chan struct{}),
listener: lis,
outputBufMax: bufSize,
pending: make(map[int64]chan JSONRPCMessage),
@@ -70,6 +78,8 @@ func newSessionConn(cmd *exec.Cmd, stdin io.WriteCloser, lis net.Listener, bufSi
// readLoop reads JSON-RPC messages from the agent's stdout and dispatches them.
// It runs until the reader returns EOF or an error.
func (sc *sessionConn) readLoop(r io.Reader) {
+ defer close(sc.readDone)
+
scanner := bufio.NewScanner(r)
// ACP messages can be large (e.g., file contents in updates).
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -132,9 +142,10 @@ func (sc *sessionConn) handleUpdate(msg JSONRPCMessage) {
return
}
+ sc.markActivity(time.Now())
+
sc.mu.Lock()
defer sc.mu.Unlock()
- sc.lastActivity = time.Now()
switch params.Update.Type {
case "agent_message_chunk", "user_message_chunk", "agent_thought_chunk":
@@ -363,6 +374,56 @@ func (sc *sessionConn) getLastActivity() time.Time {
return sc.lastActivity
}
+// markActivity records that the agent produced output at t and offers the
+// newest stamp to the asynchronous publisher. It performs no filesystem I/O.
+func (sc *sessionConn) markActivity(t time.Time) {
+ sc.mu.Lock()
+ if t.After(sc.lastActivity) {
+ sc.lastActivity = t
+ }
+ stamp := sc.lastActivity
+ publisher := sc.activityPublisher
+ sc.mu.Unlock()
+
+ if publisher != nil {
+ publisher.offer(stamp)
+ }
+}
+
+// installActivityPublisher attaches a worker after seed has been written.
+// Updates observed during the handshake are coalesced behind the seed.
+func (sc *sessionConn) installActivityPublisher(publisher *activityPublisher, seed time.Time) error {
+ sc.mu.Lock()
+ if sc.activityPublisherClosed {
+ sc.mu.Unlock()
+ publisher.close()
+ return fmt.Errorf("ACP connection closed before activity publication started")
+ }
+ if seed.After(sc.lastActivity) {
+ sc.lastActivity = seed
+ }
+ latest := sc.lastActivity
+ sc.activityPublisher = publisher
+ sc.mu.Unlock()
+
+ if latest.After(seed) {
+ publisher.offer(latest)
+ }
+ return nil
+}
+
+// closeActivityPublisher waits for any in-flight atomic write to finish.
+func (sc *sessionConn) closeActivityPublisher() {
+ sc.mu.Lock()
+ sc.activityPublisherClosed = true
+ publisher := sc.activityPublisher
+ sc.activityPublisher = nil
+ sc.mu.Unlock()
+ if publisher != nil {
+ publisher.close()
+ }
+}
+
// alive reports whether the process is still running.
func (sc *sessionConn) alive() bool {
select {
diff --git a/internal/runtime/acp/seams_test.go b/internal/runtime/acp/seams_test.go
index a86328c2a7..a764df1bff 100644
--- a/internal/runtime/acp/seams_test.go
+++ b/internal/runtime/acp/seams_test.go
@@ -63,13 +63,14 @@ func TestSeamsAcpLifecycle(t *testing.T) {
}
}
-// TestSeamsAcpTransportAndCaps pins the bespoke "acp" transport identity and the
-// (empty) capability mapping.
+// TestSeamsAcpTransportAndCaps pins the bespoke "acp" transport identity and
+// the capability mapping: acp reports activity (session/update notifications,
+// durably stamped) but never attachment (headless, no terminal).
func TestSeamsAcpTransportAndCaps(t *testing.T) {
rt, tp := newTestProvider(t).Seams()
- if caps := rt.Capabilities(); caps.ReportActivity {
- t.Fatalf("PlaceCapabilities = %+v; want ReportActivity false (acp declares none)", caps)
+ if caps := rt.Capabilities(); !caps.ReportActivity {
+ t.Fatalf("PlaceCapabilities = %+v; want ReportActivity true (acp stamps session/update activity)", caps)
}
if tp.Capabilities().ReportAttachment {
t.Fatal("TransportCapabilities.ReportAttachment should be false for acp")
diff --git a/internal/runtime/carrier.go b/internal/runtime/carrier.go
index 47d2517367..52619062db 100644
--- a/internal/runtime/carrier.go
+++ b/internal/runtime/carrier.go
@@ -14,10 +14,11 @@ import (
// Carrier out of Peek/SendKeys, not added to it.
//
// Every op returns the underlying transport error verbatim. Whether a failure
-// is fatal or best-effort is the PROVIDER facade's policy: a provider that is
-// best-effort today (e.g. Kubernetes swallows a missing pod and ignores exec
-// failures) must keep discarding the error when it delegates here — the Carrier
-// itself never swallows.
+// is fatal or best-effort is the PROVIDER facade's policy: a provider decides
+// per verb which errors to discard when it delegates here (e.g. Kubernetes
+// treats a missing pod as a no-op for SendKeys but propagates a genuine
+// transport failure to a live pod, and propagates both for Nudge) — the
+// Carrier itself never swallows.
//
// The tmux carrier ([NewTmuxCarrier]) realizes these verbs by issuing tmux
// commands over an [ExecProvider]. It is the shared driver for tmux-in-a-box
@@ -50,8 +51,8 @@ type Carrier interface {
// multiplexes sessions on distinct targets. The mapping mirrors the tmux
// commands the Kubernetes provider issues over execInPod today, so once k8s
// exposes an [ExecProvider], delegating its driving methods here is
-// argv-for-argv behavior-preserving (the provider keeps its own best-effort
-// error swallowing; see [Carrier]).
+// argv-for-argv behavior-preserving (the provider keeps its own per-verb
+// error policy; see [Carrier]).
type tmuxCarrier struct {
conn ExecProvider
target string
diff --git a/internal/runtime/herdr-provider-design.md b/internal/runtime/herdr-provider-design.md
index 7ce41f0933..644e6b84f3 100644
--- a/internal/runtime/herdr-provider-design.md
+++ b/internal/runtime/herdr-provider-design.md
@@ -1,8 +1,94 @@
# herdr as a gascity runtime provider — feasibility & interface mapping
-**Status:** IMPLEMENTED & conformance-passing (branch `feat/herdr-runtime-provider`).
+**Status:** IMPLEMENTED & conformance-passing. **Rewritten 2026-07-26 for herdr ≥0.7.5
+— read the section below first; everything under "Implemented (2026-06-29)" describes
+the 0.7.1–0.7.3 CLI, which no longer exists.**
-## Implemented (2026-06-29)
+## Rewrite for herdr ≥0.7.5 (2026-07-26) — REQUIRED READING
+
+herdr 0.7.4/0.7.5 (brew auto-update) broke the original adapter FOUR ways and produced
+the unbounded pane/shell spawn storm of 2026-07-23/25 (496 stray shells, proc-table
+exhaustion; bead az-405 has the full evidence trail). The adapter was rewritten on
+`feat/mysql-first-class-backend`; this section is the authoritative design.
+
+### What herdr changed
+
+1. **0.7.4 clears agent names on occupant change.** "Names are cleared when the occupant
+ exits, is released, or is replaced." claude's shell→TUI boot handoff replaces the
+ occupant, so every name-keyed lookup (`agent get/list`) went dark on a LIVE agent:
+ `IsRunning` false → reconciler re-Starts every tick → each wrongful Start leaked
+ placement panes. This was the 0.7.4 storm mechanism.
+2. **0.7.5 redesigned `agent start` entirely.** It now launches a supported agent
+ *kind*'s canonical executable into an EXISTING shell pane and blocks until the TUI is
+ detected (`agent start --kind --pane [--timeout ms] [-- args…]`).
+ `--no-focus/--tab/--cwd/--env` and arbitrary-argv exec are GONE — every old-style
+ Start failed AFTER placement had created a tab + shell pane, which then leaked per
+ tick (the 0.7.5 storm mechanism). cwd/env are now pane properties, set at
+ `workspace/tab create --cwd --env`.
+3. **0.7.5 enforces agent-name rules**: `^[a-z][a-z0-9_-]{0,31}$`. gc session names
+ carry rig names verbatim (`Indigo--anthony`) and can exceed 32 chars → every such
+ start rejected with `invalid_agent_name` on every tick.
+4. **Assorted surface changes:** `agent read`/`pane read` print raw text (no JSON
+ envelope); error codes are now `agent_not_found`/`pane_not_found`/`agent_pane_busy`
+ (`agent_name_taken` survives); `agent wait` takes `--until` (was `--status`); new
+ `agent prompt ` types+submits through herdr's own prompt machinery;
+ agent verbs accept a pane id as target; herdr **persists the session layout on disk**
+ and restores every tab/pane on server start.
+
+### The design
+
+- **Pane binding is the stable handle** (`panebinding.go`). Start persists pane/tab/
+ workspace ids, launch mode, exact session name, and a timestamp in the meta sidecar
+ (`GC_HERDR_*` keys). All name→pane resolution funnels through `resolveBinding`:
+ registry name first (mapped via `herdrAgentName`), then the sidecar binding verified
+ by a live `pane process-info` probe. Confirmed-gone panes clear the binding (pane ids
+ recycle); transport errors clear nothing.
+- **Launch modes** (`launchspec.go`): a clean invocation of a supported kind (claude,
+ codex, …; no shell metachars) goes through `agent start --kind` after waiting for the
+ pane's shell prompt (rc-init spawns foreground children; `agent_pane_busy` retries
+ back off 1s/2s/4s because herdr's own prompt detection lags the process table).
+ Everything else is typed into the pane as `exec /bin/sh -c ` so the pane dies
+ with the command (tmux parity), waiting until the wrapper (or an exec'd root) is
+ observed running. Empty command = the pane's shell IS the session.
+- **Mode-aware liveness**: a busy pane (foreground child, or root that is no longer a
+ shell) always reads running. A `bindModeAgent` pane at a bare prompt past a 3-minute
+ launch grace means the agent EXITED — it is **reaped** (pane closed, binding cleared):
+ nothing else ever removes an ephemeral wisp's pane (unique tab label ⇒ no future
+ Start recycles it; not-running ⇒ no Stop is issued), which leaked one zsh per
+ completed wisp. A `bindModeShell` pane runs while it exists.
+- **Start ordering matters**: the sidecar is seeded from cfg.Env AND provisionally
+ bound BEFORE the (now seconds-long) launch — reconcile ticks that fire mid-boot read
+ both stores, and an unseeded sidecar makes the ownership check roll the fresh runtime
+ back ("live runtime belongs to another session"). The binding is re-persisted after
+ launch (adoption may land on the holder's pane).
+- **Placement** (`ensurePlacement`): find-or-create workspace; close EVERY stale tab
+ carrying the session's label; create the tab with cwd+env baked into its root shell
+ pane — that root pane is the agent's pane (there is no stray pane to close anymore).
+- **Names** (`agentname.go`): `herdrAgentName` maps gc names deterministically
+ (lowercase, charmap to `-`, 24-char head + fnv32 hash beyond 32). The sidecar's
+ exact-name record is the reverse map; `ListRunning` enumerates bound sessions first
+ and appends unmapped (foreign) registry agents.
+- **Delivery**: `deliverNudge` targets the pane id via native `agent prompt`
+ (registered agents), falling back to paste+Enter for unregistered panes.
+ `WaitForIdle` uses `agent wait --until idle`. `Peek` reads via `pane read`.
+
+### Operational gotchas (learned in production, 2026-07-26)
+
+- herdr **restores the saved layout** (`~/.config/herdr/sessions//session.json`)
+ on server start — after a storm or provider era, archive/delete it or you boot into
+ dozens of stale panes (the reaper cleans bound ones; foreign ones need `pane close`).
+- The herdr server dies with the supervisor's process group on
+ `launchctl kickstart -k` — expect a server restart + layout restore + re-adoption
+ wave after supervisor restarts.
+- `gc rig suspend` holds pack agents but NOT city.toml `[[named_session]]`s pointing at
+ the rig; those respawn (mode=always) until their mode changes or the rig's sessions
+ are closed.
+- Verification history: unit suite runs against a fake-0.7.5 shell-script herdr
+ (`panebinding_provider_test.go`); live tests cover occupant swap, raw sessions, a
+ real claude kind-path boot, and the full provider conformance suite. Production
+ soak results live on bead az-405.
+
+## Implemented (2026-06-29) — PRE-0.7.5, historical
`internal/runtime/herdr/`: `client.go` (herdr CLI client), `provider.go` (the full
`runtime.Provider` + `ServerLifecycleProvider`), `capabilities.go` (`IdleWaitProvider` →
native `agent wait`, `ImmediateNudgeProvider`), `provider_live_test.go` +
diff --git a/internal/runtime/herdr/agent_name_taken.go b/internal/runtime/herdr/agent_name_taken.go
new file mode 100644
index 0000000000..adefacad4d
--- /dev/null
+++ b/internal/runtime/herdr/agent_name_taken.go
@@ -0,0 +1,48 @@
+package herdr
+
+// agentStartOps are the herdr operations resolveAgentNameTaken needs to recover
+// from an agent_name_taken rejection. They are injected as closures so the
+// recovery decision is unit-testable without a live herdr server.
+type agentStartOps struct {
+ // getAgent fetches the agent currently holding the contested name.
+ getAgent func() (agentInfo, bool, error)
+ // paneAlive reports whether the holder's pane still runs the agent process.
+ paneAlive func(paneID string) bool
+ // closePane reaps a stale holder pane.
+ closePane func(paneID string) error
+ // retryStart re-issues the original agent start after a stale holder is reaped.
+ retryStart func() (agentInfo, error)
+}
+
+// resolveAgentNameTaken recovers from herdr's agent_name_taken rejection, which
+// fires when gc re-issues `agent start` for a name herdr still holds. herdr can
+// report a live agent's pane as status=Unknown, so gc's liveness deems it dead
+// and tries to recreate it; without recovery gc then spawns a fresh tab and
+// retries indefinitely — the pane/PTY/process storm.
+//
+// startInfo/startErr are the original startAgent result. On success, or on any
+// error other than agent_name_taken, the input is returned unchanged with
+// adopted=false. On agent_name_taken it inspects the holder: if the holder's
+// process is alive it is adopted (returned as-is with adopted=true, no new pane,
+// no retry) so the caller can skip re-priming a running agent; if the holder is
+// a stale pane it is reaped and the start is retried exactly once (adopted=false,
+// a fresh agent). If the holder cannot be inspected, the original error is
+// surfaced rather than guessing.
+func resolveAgentNameTaken(startInfo agentInfo, startErr error, ops agentStartOps) (info agentInfo, adopted bool, err error) {
+ if startErr == nil {
+ return startInfo, false, nil
+ }
+ if herdrErrorCode(startErr) != "agent_name_taken" {
+ return agentInfo{}, false, startErr
+ }
+ existing, ok, gerr := ops.getAgent()
+ if gerr != nil || !ok {
+ return agentInfo{}, false, startErr
+ }
+ if ops.paneAlive(existing.PaneID) {
+ return existing, true, nil // adopt the live holder; no reap, no retry
+ }
+ _ = ops.closePane(existing.PaneID) // reap the stale holder (best effort)
+ fresh, rerr := ops.retryStart() // bounded: exactly one retry
+ return fresh, false, rerr
+}
diff --git a/internal/runtime/herdr/agent_name_taken_test.go b/internal/runtime/herdr/agent_name_taken_test.go
new file mode 100644
index 0000000000..fa603c58cc
--- /dev/null
+++ b/internal/runtime/herdr/agent_name_taken_test.go
@@ -0,0 +1,138 @@
+package herdr
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+// wrapTaken builds an error shaped exactly like client.run's output for a
+// herdr agent_name_taken rejection: the typed *herdrError wrapped with %w
+// under an outer context string.
+func wrapTaken() error {
+ return fmt.Errorf("herdr [agent start x]: %w", &herdrError{
+ Code: "agent_name_taken",
+ Message: `agent name x is already used; candidates: pane_id=w3F:pW status=Unknown`,
+ })
+}
+
+func TestHerdrErrorCodeExtractsWrappedCode(t *testing.T) {
+ if got := herdrErrorCode(wrapTaken()); got != "agent_name_taken" {
+ t.Errorf("herdrErrorCode = %q; want agent_name_taken", got)
+ }
+ if got := herdrErrorCode(errors.New("plain transport failure")); got != "" {
+ t.Errorf("herdrErrorCode(plain) = %q; want empty", got)
+ }
+ if got := herdrErrorCode(nil); got != "" {
+ t.Errorf("herdrErrorCode(nil) = %q; want empty", got)
+ }
+}
+
+// A successful start passes straight through, untouched and unadopted.
+func TestResolveAgentNameTakenSuccessPassesThrough(t *testing.T) {
+ want := agentInfo{Name: "x", PaneID: "w1:pA"}
+ got, adopted, err := resolveAgentNameTaken(want, nil, agentStartOps{})
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if got != want {
+ t.Errorf("got %+v; want %+v", got, want)
+ }
+ if adopted {
+ t.Error("adopted=true for a fresh successful start; want false")
+ }
+}
+
+// A non-taken error is surfaced verbatim — recovery must not swallow real
+// failures (e.g. openpty tab_create_failed, transport errors).
+func TestResolveAgentNameTakenNonTakenErrorSurfaces(t *testing.T) {
+ boom := errors.New("herdr [agent start x]: some_other_failure: nope")
+ called := false
+ _, adopted, err := resolveAgentNameTaken(agentInfo{}, boom, agentStartOps{
+ getAgent: func() (agentInfo, bool, error) { called = true; return agentInfo{}, false, nil },
+ })
+ if !errors.Is(err, boom) {
+ t.Errorf("err = %v; want the original non-taken error", err)
+ }
+ if adopted {
+ t.Error("adopted=true on a non-taken error; want false")
+ }
+ if called {
+ t.Error("getAgent was called for a non-taken error; recovery must not engage")
+ }
+}
+
+// agent_name_taken + the holder's process is alive → adopt it: return the
+// existing agent with adopted=true, do NOT reap, do NOT retry. This is the
+// storm-breaker, and adopted=true tells Start to skip re-priming a live agent.
+func TestResolveAgentNameTakenAdoptsLiveHolder(t *testing.T) {
+ existing := agentInfo{Name: "x", PaneID: "w3F:pW", TabID: "w3F:tE"}
+ reaped, retried := false, false
+ got, adopted, err := resolveAgentNameTaken(agentInfo{}, wrapTaken(), agentStartOps{
+ getAgent: func() (agentInfo, bool, error) { return existing, true, nil },
+ paneAlive: func(paneID string) bool { return paneID == "w3F:pW" },
+ closePane: func(string) error { reaped = true; return nil },
+ retryStart: func() (agentInfo, error) { retried = true; return agentInfo{}, nil },
+ })
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if got != existing {
+ t.Errorf("got %+v; want adopted existing %+v", got, existing)
+ }
+ if !adopted {
+ t.Error("adopted=false for a live holder; want true so Start skips re-delivery")
+ }
+ if reaped {
+ t.Error("closePane called on a live holder; must adopt, not reap")
+ }
+ if retried {
+ t.Error("retryStart called on a live holder; must adopt, not retry")
+ }
+}
+
+// agent_name_taken + the holder is a stale/dead pane → reap it, then start
+// once more (bounded: exactly one retry, no loop). A retried start is a fresh
+// agent, not an adoption, so adopted=false (Start still primes it).
+func TestResolveAgentNameTakenReapsStaleThenRetries(t *testing.T) {
+ stale := agentInfo{Name: "x", PaneID: "w3F:pOLD"}
+ fresh := agentInfo{Name: "x", PaneID: "w3F:pNEW"}
+ var reapedPane string
+ retries := 0
+ got, adopted, err := resolveAgentNameTaken(agentInfo{}, wrapTaken(), agentStartOps{
+ getAgent: func() (agentInfo, bool, error) { return stale, true, nil },
+ paneAlive: func(string) bool { return false },
+ closePane: func(paneID string) error { reapedPane = paneID; return nil },
+ retryStart: func() (agentInfo, error) { retries++; return fresh, nil },
+ })
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if reapedPane != "w3F:pOLD" {
+ t.Errorf("reaped %q; want the stale holder pane w3F:pOLD", reapedPane)
+ }
+ if retries != 1 {
+ t.Errorf("retryStart called %d times; want exactly 1 (bounded, no loop)", retries)
+ }
+ if got != fresh {
+ t.Errorf("got %+v; want fresh start %+v", got, fresh)
+ }
+ if adopted {
+ t.Error("adopted=true after reap+retry; want false (fresh start, not adoption)")
+ }
+}
+
+// agent_name_taken but the holder can't be inspected (getAgent errors or
+// reports absent) → surface the original error rather than guessing.
+func TestResolveAgentNameTakenUninspectableHolderSurfacesOriginal(t *testing.T) {
+ orig := wrapTaken()
+ _, adopted, err := resolveAgentNameTaken(agentInfo{}, orig, agentStartOps{
+ getAgent: func() (agentInfo, bool, error) { return agentInfo{}, false, nil },
+ })
+ if !errors.Is(err, orig) {
+ t.Errorf("err = %v; want the original agent_name_taken error when the holder is uninspectable", err)
+ }
+ if adopted {
+ t.Error("adopted=true when the holder is uninspectable; want false")
+ }
+}
diff --git a/internal/runtime/herdr/agentname.go b/internal/runtime/herdr/agentname.go
new file mode 100644
index 0000000000..0ac79cde46
--- /dev/null
+++ b/internal/runtime/herdr/agentname.go
@@ -0,0 +1,38 @@
+package herdr
+
+import (
+ "fmt"
+ "hash/fnv"
+ "strings"
+)
+
+// herdrAgentName maps a gc session name to a valid herdr agent name. herdr
+// ≥0.7.5 enforces ^[a-z][a-z0-9_-]{0,31}$ on agent names, while gc session
+// names carry rig names verbatim ("Indigo--anthony") and can exceed 32
+// characters — every such `agent start` was rejected with
+// invalid_agent_name on every reconcile tick. The mapping is deterministic:
+// lowercase, map any other rune to '-', prefix names that don't start with a
+// letter, and compress over-long names to a 24-char head plus an fnv32 hash
+// of the full original so distinct sessions stay distinct. The exact gc name
+// is persisted at metaBoundName, which is the reverse map ListRunning uses.
+func herdrAgentName(name string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(name) {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
+ b.WriteRune(r)
+ default:
+ b.WriteByte('-')
+ }
+ }
+ s := b.String()
+ if s == "" || s[0] < 'a' || s[0] > 'z' {
+ s = "a" + s
+ }
+ if len(s) > 32 {
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(name))
+ s = fmt.Sprintf("%s-%08x", s[:23], h.Sum32())
+ }
+ return s
+}
diff --git a/internal/runtime/herdr/agentname_test.go b/internal/runtime/herdr/agentname_test.go
new file mode 100644
index 0000000000..8fe80c994b
--- /dev/null
+++ b/internal/runtime/herdr/agentname_test.go
@@ -0,0 +1,66 @@
+package herdr
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// herdr ≥0.7.5 rejects agent names that don't match
+// ^[a-z][a-z0-9_-]{0,31}$ — gc session names carry rig names verbatim
+// ("Indigo--anthony", "CIPcodes--gastown__witness") and can exceed 32 chars,
+// so every such session failed `agent start` on every reconcile tick (a
+// bounded but hot retry loop found live in the anthony flip). herdrAgentName
+// maps any gc session name to a valid, deterministic herdr name.
+
+var validHerdrName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
+
+func TestHerdrAgentNameValidNamesPassThrough(t *testing.T) {
+ for _, name := range []string{"mayor", "gastown__witness", "kit--anthony", "polecat-gc-wisp-3nvj3yx"} {
+ if got := herdrAgentName(name); got != name {
+ t.Errorf("herdrAgentName(%q) = %q; want unchanged", name, got)
+ }
+ }
+}
+
+func TestHerdrAgentNameLowercasesAndMapsInvalid(t *testing.T) {
+ tests := map[string]string{
+ "Indigo--anthony": "indigo--anthony",
+ "CIPcodes--gastown__witness": "cipcodes--gastown__witness",
+ "a.b/c": "a-b-c",
+ }
+ for in, want := range tests {
+ if got := herdrAgentName(in); got != want {
+ t.Errorf("herdrAgentName(%q) = %q; want %q", in, got, want)
+ }
+ }
+}
+
+func TestHerdrAgentNameAlwaysValid(t *testing.T) {
+ cases := []string{
+ "GunnInternships--gastown__refinery", // >32 chars
+ "review_pdf_to_latex--gastown__witness",
+ "9starts-with-digit",
+ "_starts-with-underscore",
+ "",
+ strings.Repeat("x", 100),
+ "ALLCAPS", "Ünïcode--agent",
+ }
+ for _, in := range cases {
+ got := herdrAgentName(in)
+ if !validHerdrName.MatchString(got) {
+ t.Errorf("herdrAgentName(%q) = %q; not a valid herdr agent name", in, got)
+ }
+ }
+}
+
+func TestHerdrAgentNameLongNamesStayDistinctAndStable(t *testing.T) {
+ a := herdrAgentName("GunnInternships--gastown__refinery")
+ b := herdrAgentName("GunnInternships--gastown__witnessx")
+ if a == b {
+ t.Fatalf("distinct long names collided: %q", a)
+ }
+ if a != herdrAgentName("GunnInternships--gastown__refinery") {
+ t.Error("mapping is not deterministic")
+ }
+}
diff --git a/internal/runtime/herdr/capabilities.go b/internal/runtime/herdr/capabilities.go
index 792e1bff26..edc814ecff 100644
--- a/internal/runtime/herdr/capabilities.go
+++ b/internal/runtime/herdr/capabilities.go
@@ -23,15 +23,17 @@ var (
)
// WaitForIdle blocks until herdr reports the agent idle or the timeout elapses,
-// via herdr's native `agent wait --status idle` — vs the pane-polling tmux does.
-// Either outcome (idle reached or timed out) means the caller may proceed, so
-// only context cancellation surfaces as an error; the timeout is a hard bound.
+// via herdr's native `agent wait --until idle` (the ≥0.7.5 flag spelling) — vs
+// the pane-polling tmux does. Either outcome (idle reached or timed out) means
+// the caller may proceed — as does an unregistered session (raw shell panes
+// have no agent to wait on) — so only context cancellation surfaces as an
+// error; the timeout is a hard bound.
func (p *Provider) WaitForIdle(ctx context.Context, name string, timeout time.Duration) error {
ms := int(timeout / time.Millisecond)
if ms < 1 {
ms = 1
}
- _, _ = p.c.run(ctx, "agent", "wait", name, "--status", "idle", "--timeout", strconv.Itoa(ms))
+ _, _ = p.c.run(ctx, "agent", "wait", herdrAgentName(name), "--until", "idle", "--timeout", strconv.Itoa(ms))
return ctx.Err()
}
diff --git a/internal/runtime/herdr/client.go b/internal/runtime/herdr/client.go
index 62a3629429..32598fc3b3 100644
--- a/internal/runtime/herdr/client.go
+++ b/internal/runtime/herdr/client.go
@@ -47,6 +47,22 @@ type herdrError struct {
Message string `json:"message"`
}
+// Error renders the herdr-reported failure as ": ", matching the
+// text run() previously formatted inline; wrapping it with %w additionally lets
+// callers recover the typed error (and its Code) via errors.As.
+func (e *herdrError) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) }
+
+// herdrErrorCode returns the herdr-reported error code wrapped anywhere in err
+// (via *herdrError), or "" if err carries no herdr error. Callers branch on
+// specific herdr failures (e.g. "agent_name_taken") without matching message text.
+func herdrErrorCode(err error) string {
+ var he *herdrError
+ if errors.As(err, &he) {
+ return he.Code
+ }
+ return ""
+}
+
type envelope struct {
Result json.RawMessage `json:"result"`
Error *herdrError `json:"error"`
@@ -72,7 +88,7 @@ func (c *client) run(ctx context.Context, args ...string) (json.RawMessage, erro
return nil, fmt.Errorf("herdr %v: decode response: %w", args, err)
}
if env.Error != nil {
- return nil, fmt.Errorf("herdr %v: %s: %s", args, env.Error.Code, env.Error.Message)
+ return nil, fmt.Errorf("herdr %v: %w", args, env.Error)
}
return env.Result, nil
}
@@ -88,23 +104,27 @@ type agentInfo struct {
Cwd string `json:"cwd"`
}
-// startAgent → `herdr agent start --no-focus [--tab ] [--cwd ]
-// [--env k=v …] -- `. A non-empty tabID places the agent in that tab;
-// without it herdr splits the focused tab into a new pane.
-func (c *client) startAgent(ctx context.Context, name, tabID, cwd string, env map[string]string, argv []string) (agentInfo, error) {
- args := []string{"agent", "start", name, "--no-focus"}
- if tabID != "" {
- args = append(args, "--tab", tabID)
- }
- if cwd != "" {
- args = append(args, "--cwd", cwd)
- }
- for k, v := range env {
- args = append(args, "--env", k+"="+v)
- }
- args = append(args, "--")
- args = append(args, argv...)
- res, err := c.run(ctx, args...)
+// agentStartTimeoutMS bounds herdr's own wait for the launched agent TUI to
+// be detected and interactive-ready (`agent start --timeout`). herdr requires
+// >3000 and defaults to 30000; sized up to cover cold, concurrent claude
+// boots during a town-wide restart.
+const agentStartTimeoutMS = 60000
+
+// startAgentKind → `herdr agent start --kind --pane
+// --timeout [-- ]` (herdr ≥0.7.5). herdr launches the kind's
+// canonical executable with args inside the existing shell pane and blocks
+// until the agent TUI is detected and interactive-ready — its native
+// claude-detection, which replaces the pre-0.7.5 exec-argv launch (whose
+// shell→TUI occupant handoff is what cleared agent names mid-boot). cwd and
+// env are properties of the pane (set at tab/workspace creation), not of the
+// agent start.
+func (c *client) startAgentKind(ctx context.Context, name, kind, paneID string, args []string) (agentInfo, error) {
+ cli := []string{"agent", "start", name, "--kind", kind, "--pane", paneID, "--timeout", strconv.Itoa(agentStartTimeoutMS)}
+ if len(args) > 0 {
+ cli = append(cli, "--")
+ cli = append(cli, args...)
+ }
+ res, err := c.run(ctx, cli...)
if err != nil {
return agentInfo{}, err
}
@@ -117,6 +137,15 @@ func (c *client) startAgent(ctx context.Context, name, tabID, cwd string, env ma
return wrap.Agent, nil
}
+// agentPrompt → `herdr agent prompt ` (herdr ≥0.7.5): types
+// text into a registered agent's input and submits it through herdr's own
+// prompt machinery — the reliable replacement for the paste+Enter+confirm
+// dance. target is an agent name or the pane id hosting it.
+func (c *client) agentPrompt(ctx context.Context, target, text string) error {
+ _, err := c.run(ctx, "agent", "prompt", target, text)
+ return err
+}
+
// listAgents → `herdr agent list`.
func (c *client) listAgents(ctx context.Context) ([]agentInfo, error) {
res, err := c.run(ctx, "agent", "list")
@@ -132,27 +161,46 @@ func (c *client) listAgents(ctx context.Context) ([]agentInfo, error) {
return wrap.Agents, nil
}
-// read → `herdr agent read --source [--lines n]`. Use
-// "visible" for the current screen (the liveness/fingerprint snapshot);
-// "recent"/"recent-unwrapped" are scrollback only.
-func (c *client) read(ctx context.Context, name, source string, lines int) (string, error) {
- args := []string{"agent", "read", name, "--source", source}
+// paneRead → `herdr pane read --source [--lines n]`
+// (herdr ≥0.7.5). Reads any pane's screen without needing a registered agent
+// (raw shell sessions never register one). Use "visible" for the current
+// screen (the liveness/fingerprint snapshot). On 0.7.5 the CLI prints the
+// text raw rather than in the JSON envelope, so this parses failures out of
+// an envelope only when one is present.
+func (c *client) paneRead(ctx context.Context, paneID, source string, lines int) (string, error) {
+ args := []string{"pane", "read", paneID, "--source", source}
if lines > 0 {
args = append(args, "--lines", strconv.Itoa(lines))
}
- res, err := c.run(ctx, args...)
+ out, err := c.runRaw(ctx, args...)
if err != nil {
return "", err
}
- var wrap struct {
- Read struct {
- Text string `json:"text"`
- } `json:"read"`
+ return out, nil
+}
+
+// runRaw executes a herdr verb whose success output is plain text, not the
+// JSON envelope (0.7.5 `pane read`). Failures still arrive as an envelope on
+// stdout or as stderr text, so an output that decodes to an envelope carrying
+// an error is surfaced as that error; anything else is returned verbatim.
+func (c *client) runRaw(ctx context.Context, args ...string) (string, error) {
+ full := append([]string{"--session", c.session}, args...)
+ out, err := exec.CommandContext(ctx, c.bin, full...).Output()
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && len(ee.Stderr) > 0 {
+ return "", fmt.Errorf("herdr %v: %s", args, ee.Stderr)
+ }
+ return "", fmt.Errorf("herdr %v: %w", args, err)
}
- if err := json.Unmarshal(res, &wrap); err != nil {
- return "", fmt.Errorf("herdr agent read: decode: %w", err)
+ trimmed := strings.TrimSpace(string(out))
+ if strings.HasPrefix(trimmed, "{") {
+ var env envelope
+ if jerr := json.Unmarshal([]byte(trimmed), &env); jerr == nil && env.Error != nil {
+ return "", fmt.Errorf("herdr %v: %w", args, env.Error)
+ }
}
- return wrap.Read.Text, nil
+ return string(out), nil
}
// proc is one process in a pane's foreground tree.
@@ -195,72 +243,35 @@ func (c *client) paneRun(ctx context.Context, paneID, command string) error {
return err
}
-// deliverNudge types a nudge into the agent's input and submits it, then
-// confirms the submit actually landed. The text is injected with `pane run`
-// (paste semantics: multi-line content is preserved and the paste's own trailing
-// newline is swallowed by the TUI, so the text never submits on its own).
-//
-// Submission is the hard part. Two facts, learned empirically against herdr 0.7.1
-// + the Claude Code TUI:
-//
-// - The TUI must be at a ready input prompt: a submit delivered mid-boot is
-// swallowed. Callers deliver to a ready agent — Start waits for idle first
-// (see startupNudgeIdleTimeout); the Nudge path targets running agents.
-// - A submit that races the paste-commit is swallowed, stranding the prompt
-// typed-but-unsubmitted — the agent then idles forever with work it never
-// began (the missed startup-nudge stall).
-//
-// The prior open-loop form (settle → CR → settle → CR, via `agent send "\r"`) was
-// not enough under concurrent restart-time boot load: both CRs raced the paste
-// and the nudge stranded, and the swallowed result hid it. This is now
-// closed-loop: press Enter as a real key event (`pane send-keys`, which submits
-// reliably where a pasted `\r` did not), then verify via `agent get` that the
-// agent actually left its idle prompt. Retry the Enter until it does, bounded so
-// a nudge that legitimately produces no work cannot spin. A redundant Enter on an
-// already-submitted/empty prompt is a harmless no-op. Returns an error if the
-// submit never confirms, so the caller can surface it instead of silently
-// leaving a stranded agent.
-//
-// Contract: inject + submit by pane id, confirm by agent name.
-func (c *client) deliverNudge(ctx context.Context, paneID, name, text string) error {
- if err := c.paneRun(ctx, paneID, text); err != nil {
- return err
+// deliverNudge types a nudge into the session and submits it. Registered
+// agents (the kind-launch path) go through herdr ≥0.7.5's native
+// `agent prompt`, which owns the type+submit handshake that the pre-0.7.5
+// paste+Enter+confirm dance approximated — targeting the pane id, which agent
+// verbs accept even after the registry name is unavailable to the caller.
+// Panes with no registered agent (raw `exec /bin/sh -c` sessions, bare
+// shells) fall back to paste + Enter: there is no TUI prompt machinery to
+// confirm against, so delivery is best-effort by construction.
+func (c *client) deliverNudge(ctx context.Context, paneID, text string) error {
+ err := c.agentPrompt(ctx, paneID, text)
+ if err == nil {
+ return nil
}
- time.Sleep(submitSettleDelay) // let the paste commit before the first submit
- var lastErr error
- for attempt := 0; attempt < submitMaxAttempts; attempt++ {
- if err := c.sendKeys(ctx, paneID, "Enter"); err != nil {
- lastErr = err // transient send failure; verify + retry within the bound
- }
- time.Sleep(submitSettleDelay)
- info, ok, err := c.getAgent(ctx, name)
- switch {
- case err != nil:
- lastErr = err // transient read failure; retry within the bound
- case !ok:
- return fmt.Errorf("herdr deliverNudge: agent %q vanished before submit confirmed", name)
- case !strings.EqualFold(strings.TrimSpace(info.AgentStatus), "idle"):
- return nil // left the idle prompt → submit landed, agent is running
- }
+ if !strings.Contains(err.Error(), "not_found") && !strings.Contains(err.Error(), "not found") {
+ return err
}
- if lastErr != nil {
- return fmt.Errorf("herdr deliverNudge: %q still idle after %d submit attempts: %w", name, submitMaxAttempts, lastErr)
+ // No registered agent on this pane: paste, settle, submit.
+ if err := c.paneRun(ctx, paneID, text); err != nil {
+ return err
}
- return fmt.Errorf("herdr deliverNudge: %q still idle after %d submit attempts (nudge typed-but-unsubmitted?)", name, submitMaxAttempts)
+ time.Sleep(submitSettleDelay)
+ return c.sendKeys(ctx, paneID, "Enter")
}
-// submitSettleDelay is how long deliverNudge waits for a `pane run` paste to
-// commit in the TUI before each submit Enter and before re-reading agent status.
-// A submit that races the paste is swallowed; ~1s clears it with margin even
-// under the concurrent boot load of a town-wide restart.
+// submitSettleDelay is how long the unregistered-pane fallback waits for a
+// `pane run` paste to commit before the submit Enter (a submit racing the
+// paste is swallowed).
const submitSettleDelay = 1 * time.Second
-// submitMaxAttempts bounds the closed-loop submit: ~submitMaxAttempts·settle is
-// the worst-case latency before deliverNudge gives up and returns an error. Sized
-// to cover a slow paste-commit under restart-time load without spinning on a
-// nudge that legitimately leaves the agent idle.
-const submitMaxAttempts = 5
-
// closePane → `herdr pane close `.
func (c *client) closePane(ctx context.Context, paneID string) error {
_, err := c.run(ctx, "pane", "close", paneID)
@@ -290,9 +301,11 @@ func (c *client) getAgent(ctx context.Context, name string) (agentInfo, bool, er
//
// herdr's tree is workspace › tab › pane. To give each agent its own switchable
// space (vs tiling every agent as a pane in one tab), Start groups agents one
-// workspace per rig/town and one tab per agent. `workspace create` and `tab
-// create` each auto-spawn a stray shell pane; the caller closes it so the tab
-// holds only the agent.
+// workspace per rig/town and one tab per agent. Under herdr ≥0.7.5 the shell
+// pane that `workspace create`/`tab create` auto-spawns IS the agent's pane —
+// agents launch into an existing shell pane, and cwd/env are set here at pane
+// creation (there is no longer a stray pane to close, which is what leaked one
+// shell per wrongful Start in the spawn storm).
type workspaceInfo struct {
WorkspaceID string `json:"workspace_id"`
@@ -324,11 +337,18 @@ func (c *client) findWorkspace(ctx context.Context, label string) (string, error
return "", nil
}
-// workspaceCreate makes a workspace labeled label and returns its id plus the
-// default tab and stray shell pane herdr auto-spawns inside it (the caller
-// repurposes the tab and closes the stray pane).
-func (c *client) workspaceCreate(ctx context.Context, label string) (wsID, tabID, strayPane string, err error) {
- res, err := c.run(ctx, "workspace", "create", "--label", label, "--no-focus")
+// workspaceCreate makes a workspace labeled label whose root shell pane is
+// created with the given cwd and env, and returns the workspace id plus the
+// default tab and root pane (the agent's pane) herdr auto-spawns inside it.
+func (c *client) workspaceCreate(ctx context.Context, label, cwd string, env map[string]string) (wsID, tabID, paneID string, err error) {
+ args := []string{"workspace", "create", "--label", label, "--no-focus"}
+ if cwd != "" {
+ args = append(args, "--cwd", cwd)
+ }
+ for k, v := range env {
+ args = append(args, "--env", k+"="+v)
+ }
+ res, err := c.run(ctx, args...)
if err != nil {
return "", "", "", err
}
@@ -349,30 +369,33 @@ func (c *client) workspaceCreate(ctx context.Context, label string) (wsID, tabID
return wrap.Workspace.WorkspaceID, wrap.Tab.TabID, wrap.RootPane.PaneID, nil
}
-// findTab returns the id of the tab in wsID whose label matches, or "".
-func (c *client) findTab(ctx context.Context, wsID, label string) (string, error) {
+// listTabs returns the tabs in wsID.
+func (c *client) listTabs(ctx context.Context, wsID string) ([]tabInfo, error) {
res, err := c.run(ctx, "tab", "list", "--workspace", wsID)
if err != nil {
- return "", err
+ return nil, err
}
var wrap struct {
Tabs []tabInfo `json:"tabs"`
}
if err := json.Unmarshal(res, &wrap); err != nil {
- return "", fmt.Errorf("herdr tab list: decode: %w", err)
+ return nil, fmt.Errorf("herdr tab list: decode: %w", err)
}
- for _, t := range wrap.Tabs {
- if t.Label == label {
- return t.TabID, nil
- }
- }
- return "", nil
+ return wrap.Tabs, nil
}
-// tabCreate makes a tab labeled label in wsID and returns its id plus the stray
-// shell pane herdr auto-spawns (the caller closes it after the agent starts).
-func (c *client) tabCreate(ctx context.Context, wsID, label string) (tabID, strayPane string, err error) {
- res, err := c.run(ctx, "tab", "create", "--workspace", wsID, "--label", label, "--no-focus")
+// tabCreate makes a tab labeled label in wsID whose root shell pane is created
+// with the given cwd and env, and returns the tab id plus that root pane (the
+// agent's pane).
+func (c *client) tabCreate(ctx context.Context, wsID, label, cwd string, env map[string]string) (tabID, paneID string, err error) {
+ args := []string{"tab", "create", "--workspace", wsID, "--label", label}
+ if cwd != "" {
+ args = append(args, "--cwd", cwd)
+ }
+ for k, v := range env {
+ args = append(args, "--env", k+"="+v)
+ }
+ res, err := c.run(ctx, args...)
if err != nil {
return "", "", err
}
@@ -396,32 +419,45 @@ func (c *client) tabRename(ctx context.Context, tabID, label string) error {
return err
}
-// ensurePlacement resolves where an agent's pane should live: it finds or creates
-// the per-rig/town workspace wsLabel, then finds or creates the per-agent tab
-// tabLabel inside it. It returns the tab id and, when herdr auto-spawned a stray
-// shell pane (new workspace or new tab), that pane's id so Start can close it —
-// leaving the tab holding only the agent. A reused existing tab returns "".
-func (c *client) ensurePlacement(ctx context.Context, wsLabel, tabLabel string) (tabID, strayPane string, err error) {
+// tabClose closes a tab and its panes (used to recycle a stale tab left by a
+// previous life of the same session before creating its replacement).
+func (c *client) tabClose(ctx context.Context, tabID string) error {
+ _, err := c.run(ctx, "tab", "close", tabID)
+ return err
+}
+
+// ensurePlacement resolves where an agent should live and returns its tab id
+// plus the fresh shell pane the agent will launch into: it finds or creates
+// the per-rig/town workspace wsLabel, then creates the per-agent tab tabLabel
+// inside it with the agent's cwd and env baked into the pane. A stale tab
+// with the same label (left by a previous life of this session — e.g. an
+// exited agent whose pane sits at a shell prompt) is closed first, so every
+// Start gets a clean shell with the right cwd/env and dead panes never
+// accumulate across restarts.
+func (c *client) ensurePlacement(ctx context.Context, wsLabel, tabLabel, cwd string, env map[string]string) (tabID, paneID string, err error) {
wsID, err := c.findWorkspace(ctx, wsLabel)
if err != nil {
return "", "", err
}
if wsID == "" {
// New workspace: repurpose the default tab herdr spawns for this agent.
- _, tabID, strayPane, err = c.workspaceCreate(ctx, wsLabel)
+ _, tabID, paneID, err = c.workspaceCreate(ctx, wsLabel, cwd, env)
if err != nil {
return "", "", err
}
_ = c.tabRename(ctx, tabID, tabLabel) // cosmetic; ignore failure
- return tabID, strayPane, nil
+ return tabID, paneID, nil
}
- if tabID, err = c.findTab(ctx, wsID, tabLabel); err != nil {
+ tabs, err := c.listTabs(ctx, wsID)
+ if err != nil {
return "", "", err
}
- if tabID != "" {
- return tabID, "", nil // reuse existing tab; no stray pane to close
+ for _, tb := range tabs {
+ if tb.Label == tabLabel {
+ _ = c.tabClose(ctx, tb.TabID) // best-effort: replaced below either way
+ }
}
- return c.tabCreate(ctx, wsID, tabLabel)
+ return c.tabCreate(ctx, wsID, tabLabel, cwd, env)
}
// ── shared session-server lifecycle ──────────────────────────────────────────
diff --git a/internal/runtime/herdr/kindpath_live_test.go b/internal/runtime/herdr/kindpath_live_test.go
new file mode 100644
index 0000000000..98235b7828
--- /dev/null
+++ b/internal/runtime/herdr/kindpath_live_test.go
@@ -0,0 +1,74 @@
+package herdr
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+)
+
+// TestProviderLiveClaudeKindPath drives the herdr ≥0.7.5 kind-launch path
+// against a real herdr AND a real claude binary: Start places a shell pane
+// and has herdr launch + detect claude in it (native claude-detection), the
+// agent is registered under the session name, liveness holds across checks
+// (with a re-issued Start refusing), and Stop tears the pane down. Skipped
+// when herdr or claude is unavailable or in -short mode.
+func TestProviderLiveClaudeKindPath(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping live herdr+claude test in -short mode")
+ }
+ if _, err := exec.LookPath("herdr"); err != nil {
+ t.Skip("herdr not installed")
+ }
+ if _, err := exec.LookPath("claude"); err != nil {
+ t.Skip("claude not installed")
+ }
+
+ p := New("gctest-kind", t.TempDir(), t.TempDir(), 0, 0)
+ _ = p.Stop("kindsmoke")
+ t.Cleanup(func() { _ = p.Stop("kindsmoke"); _ = p.TeardownServer() })
+
+ ctx := context.Background()
+ cfg := runtime.Config{
+ WorkDir: t.TempDir(),
+ Command: "claude",
+ Env: map[string]string{"GC_SESSION_ID": "gctest-kind-session"},
+ }
+ if err := p.Start(ctx, "kindsmoke", cfg); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ // herdr registered the agent under the session name (kind path).
+ if _, ok, err := p.c.getAgent(ctx, "kindsmoke"); err != nil || !ok {
+ t.Fatalf("agent get kindsmoke = ok=%v, %v; want registered", ok, err)
+ }
+ if mode, _ := p.GetMeta("kindsmoke", metaBoundMode); mode != bindModeAgent {
+ t.Errorf("bound mode = %q; want %q", mode, bindModeAgent)
+ }
+ if pane, _ := p.GetMeta("kindsmoke", metaBoundPane); pane == "" {
+ t.Error("bound pane empty after kind Start")
+ }
+
+ if !p.IsRunning("kindsmoke") {
+ t.Error("IsRunning = false after kind Start")
+ }
+ if live := p.ObserveLiveness("kindsmoke", nil); !live.Running || !live.Alive {
+ t.Errorf("ObserveLiveness = %+v; want Running=true Alive=true", live)
+ }
+ if err := p.Start(ctx, "kindsmoke", cfg); !errors.Is(err, runtime.ErrSessionExists) {
+ t.Errorf("re-issued Start = %v; want ErrSessionExists", err)
+ }
+
+ if err := p.Stop("kindsmoke"); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ for i := 0; i < 15 && p.IsRunning("kindsmoke"); i++ {
+ time.Sleep(200 * time.Millisecond)
+ }
+ if p.IsRunning("kindsmoke") {
+ t.Error("IsRunning = true after Stop")
+ }
+}
diff --git a/internal/runtime/herdr/launchspec.go b/internal/runtime/herdr/launchspec.go
new file mode 100644
index 0000000000..36bfbab99c
--- /dev/null
+++ b/internal/runtime/herdr/launchspec.go
@@ -0,0 +1,63 @@
+package herdr
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/gastownhall/gascity/internal/shellquote"
+)
+
+// launchSpec is how Start launches a session's command under herdr ≥0.7.5,
+// whose `agent start` no longer execs arbitrary argv: it launches a supported
+// agent kind's canonical executable into an existing shell pane and waits for
+// TUI detection.
+type launchSpec struct {
+ // Kind is the herdr agent kind for `agent start --kind` (with Args as the
+ // executable's arguments) when the command is a clean invocation of a
+ // supported kind. The session gets a registered herdr agent: native
+ // detection, prompt/wait delivery, and status-backed liveness.
+ Kind string
+ Args []string
+ // Raw is the fallback: the command is typed into the pane shell as
+ // `exec /bin/sh -c ` so the pane dies with the command (tmux parity).
+ // Only pane-level tracking is available; the sidecar pane binding is the
+ // session handle.
+ Raw string
+}
+
+// herdrAgentKinds are the agent kinds herdr 0.7.5 can launch and detect
+// (`herdr agent start --help`). A kind here only gates the *attempt*; an
+// unsupported invocation surfaces as an agent-start error, and commands that
+// need a real shell fall back to Raw before any kind matching.
+var herdrAgentKinds = map[string]bool{
+ "pi": true, "claude": true, "codex": true, "gemini": true, "cursor": true,
+ "devin": true, "agy": true, "cline": true, "omp": true, "mastracode": true,
+ "opencode": true, "copilot": true, "kimi": true, "kiro": true, "droid": true,
+ "amp": true, "grok": true, "hermes": true, "kilo": true, "qodercli": true,
+ "maki": true,
+}
+
+// launchShellMetachars are characters whose presence means the command needs a
+// real shell (operators, substitution, env-prefix assignments): conservative —
+// quoted occurrences also trigger the fallback, which still runs correctly.
+const launchShellMetachars = "|&;<>()`$=\n"
+
+// launchSpecFor parses a session command into its herdr launch mode. A blank
+// command returns the zero spec: the pane's own shell is the session.
+func launchSpecFor(command string) launchSpec {
+ command = strings.TrimSpace(command)
+ if command == "" {
+ return launchSpec{}
+ }
+ if strings.ContainsAny(command, launchShellMetachars) {
+ return launchSpec{Raw: command}
+ }
+ parts := shellquote.Split(command)
+ if len(parts) == 0 {
+ return launchSpec{Raw: command}
+ }
+ if kind := filepath.Base(parts[0]); herdrAgentKinds[kind] {
+ return launchSpec{Kind: kind, Args: parts[1:]}
+ }
+ return launchSpec{Raw: command}
+}
diff --git a/internal/runtime/herdr/launchspec_test.go b/internal/runtime/herdr/launchspec_test.go
new file mode 100644
index 0000000000..3c8eec6017
--- /dev/null
+++ b/internal/runtime/herdr/launchspec_test.go
@@ -0,0 +1,72 @@
+package herdr
+
+import (
+ "reflect"
+ "testing"
+)
+
+// launchSpecFor decides how Start launches a session's command under herdr
+// ≥0.7.5, whose `agent start` no longer execs arbitrary argv: it launches a
+// supported agent *kind*'s canonical executable into an existing shell pane
+// and waits for TUI detection. Clean invocations of a supported kind take
+// that path (registered agent: native detection, prompt, wait, status);
+// everything else is typed into the pane shell as `exec /bin/sh -c ` so
+// the pane still dies with the command (tmux parity).
+
+func TestLaunchSpecForCleanClaudeCommandUsesKind(t *testing.T) {
+ got := launchSpecFor(`claude --dangerously-skip-permissions --effort max --settings "/city root/.gc/settings.json"`)
+ if got.Kind != "claude" {
+ t.Fatalf("Kind = %q; want claude", got.Kind)
+ }
+ want := []string{"--dangerously-skip-permissions", "--effort", "max", "--settings", "/city root/.gc/settings.json"}
+ if !reflect.DeepEqual(got.Args, want) {
+ t.Errorf("Args = %q; want %q", got.Args, want)
+ }
+ if got.Raw != "" {
+ t.Errorf("Raw = %q; want empty on the kind path", got.Raw)
+ }
+}
+
+func TestLaunchSpecForPathQualifiedKind(t *testing.T) {
+ got := launchSpecFor("/usr/local/bin/claude --resume abc123")
+ if got.Kind != "claude" || got.Raw != "" {
+ t.Fatalf("spec = %+v; want kind claude via basename", got)
+ }
+}
+
+// Shell metachars mean the command needs a real shell: fall back to raw even
+// when it mentions a known kind. Conservative is correct — the raw path still
+// runs it; only herdr-native registration is lost.
+func TestLaunchSpecForShellMetacharsFallBackToRaw(t *testing.T) {
+ for _, cmd := range []string{
+ "claude --flag && echo done",
+ "claude -p 'hi'; sleep 1",
+ "claude --append-system-prompt \"use $HOME wisely\"",
+ "FOO=bar claude --flag",
+ "claude | tee log",
+ "for i in $(seq 3); do echo $i; done",
+ } {
+ got := launchSpecFor(cmd)
+ if got.Kind != "" || got.Raw != cmd {
+ t.Errorf("launchSpecFor(%q) = %+v; want raw fallback", cmd, got)
+ }
+ }
+}
+
+// Unknown executables are raw.
+func TestLaunchSpecForUnknownExecutableIsRaw(t *testing.T) {
+ got := launchSpecFor("python3 worker.py --queue main")
+ if got.Kind != "" || got.Raw != "python3 worker.py --queue main" {
+ t.Errorf("spec = %+v; want raw", got)
+ }
+}
+
+// Empty command: the shell pane itself is the session (old /bin/sh behavior).
+func TestLaunchSpecForEmptyCommandIsBareShell(t *testing.T) {
+ for _, cmd := range []string{"", " "} {
+ got := launchSpecFor(cmd)
+ if got.Kind != "" || got.Raw != "" {
+ t.Errorf("launchSpecFor(%q) = %+v; want zero spec (bare shell)", cmd, got)
+ }
+ }
+}
diff --git a/internal/runtime/herdr/panebinding.go b/internal/runtime/herdr/panebinding.go
new file mode 100644
index 0000000000..228a757ca0
--- /dev/null
+++ b/internal/runtime/herdr/panebinding.go
@@ -0,0 +1,302 @@
+package herdr
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ── pane binding: the stable agent handle under herdr ≥0.7.4 ─────────────────
+//
+// herdr ≥0.7.4 clears an agent's *name* from its registry when the pane
+// occupant exits, is released, or is replaced. On 0.7.5 that is by design —
+// `agent start` detects the launched TUI and a cleared name means the agent
+// exited — but it also means every name-keyed lookup can go dark while the
+// session's pane lives on (raw shell sessions are never registered at all).
+// Reading a live session as absent is the spawn storm: IsRunning goes false,
+// the reconciler re-Starts every tick, and each wrongful Start leaks a pane.
+// The *pane id* is the stable handle, so Start persists it (plus the launch
+// mode) in the metadata sidecar and every name→pane resolution falls back to
+// it, probed live before it is trusted (pane ids recycle).
+
+// Sidecar keys for the placement herdr assigned at Start. Namespaced away from
+// the GC_* env keys seedMetaFromEnv mirrors into the same store.
+const (
+ metaBoundPane = "GC_HERDR_PANE_ID"
+ metaBoundTab = "GC_HERDR_TAB_ID"
+ metaBoundWorkspace = "GC_HERDR_WORKSPACE_ID"
+ metaBoundMode = "GC_HERDR_LAUNCH_MODE"
+ // metaBoundName holds the exact session name (sidecar directories use the
+ // sanitized form, which is lossy), so ListRunning can enumerate bound
+ // sessions that herdr's registry does not know about.
+ metaBoundName = "GC_HERDR_SESSION_NAME"
+ // metaBoundAt holds the unix-seconds timestamp of the binding, so the
+ // exited-agent reap can distinguish a pane whose agent is still being
+ // launched (fresh binding) from one whose agent exited (old binding).
+ metaBoundAt = "GC_HERDR_BOUND_AT"
+)
+
+// bindingLaunchGrace is how long after binding a pane may sit at a bare
+// shell prompt in bindModeAgent before it reads as "agent exited" and is
+// reaped. Sized past the whole launch window (shell readiness wait +
+// herdr's agent-start timeout + busy retries), so an in-flight Start's
+// provisionally bound pane is never closed from under it.
+const bindingLaunchGrace = 3 * time.Minute
+
+// Launch modes persisted at metaBoundMode. They pick the liveness rule for
+// the binding fallback: a registered agent (bindModeAgent) whose pane is back
+// at a bare shell prompt has exited — its pane still resolves so Stop can
+// close it, but the session is not running; a raw/bare shell session
+// (bindModeShell) runs as long as its pane exists, because `exec /bin/sh -c`
+// panes die with their command.
+const (
+ bindModeAgent = "agent"
+ bindModeShell = "shell"
+)
+
+// paneProbe is what probing a bound pane learned: whether the pane still
+// exists, and whether something beyond the pane's own shell is in the
+// foreground (a foreground process with a pid other than the shell's).
+type paneProbe struct {
+ Exists bool
+ Busy bool
+}
+
+// paneLookupOps are the operations resolveBinding needs, injected as closures
+// so the resolution decision is unit-testable without a live herdr server
+// (mirrors agentStartOps).
+type paneLookupOps struct {
+ // getAgent is the name-keyed registry lookup (fast path while the name lives).
+ getAgent func() (agentInfo, bool, error)
+ // boundPane reads the sidecar pane binding ("" when absent).
+ boundPane func() string
+ // boundMode reads the persisted launch mode ("" on pre-upgrade bindings).
+ boundMode func() string
+ // boundAge reports how long ago the binding was persisted (a very large
+ // value when unknown, so pre-upgrade bindings are still reapable).
+ boundAge func() time.Duration
+ // reapPane closes an exited agent's leftover pane (best-effort).
+ reapPane func(paneID string)
+ // probePane inspects the bound pane. A zero probe with nil error means
+ // herdr confirmed the pane gone; a non-nil error means the probe itself
+ // failed (transport), which proves nothing either way.
+ probePane func(paneID string) (paneProbe, error)
+ // clearBinding drops a binding whose pane herdr confirmed gone, so a
+ // recycled pane id can never resurrect a dead session.
+ clearBinding func()
+}
+
+// resolveBinding resolves a session name to its herdr pane id and a running
+// verdict: registry name lookup first (a live name is a running agent), then
+// the sidecar pane binding, trusted only after a live probe. Running is
+// mode-aware: a busy pane always runs; a bare shell prompt runs only for
+// bindModeShell. A bindModeAgent pane at a bare prompt past the launch grace
+// means the agent EXITED — under tmux the pane would have died with the
+// process, so it is reaped here (pane closed, binding cleared): nothing else
+// ever reaps it for an ephemeral wisp, whose unique tab label sees no future
+// Start and whose not-running verdict means no Stop — one leaked shell pane
+// per completed wisp otherwise. Within the grace the pane resolves untouched
+// (an in-flight Start provisionally bound it). A binding whose pane is
+// confirmed gone is cleared and resolves absent; a transport failure on
+// either tier surfaces as an error and clears nothing.
+func resolveBinding(ops paneLookupOps) (paneID string, running bool, err error) {
+ a, ok, err := ops.getAgent()
+ if err != nil {
+ return "", false, err
+ }
+ if ok && a.PaneID != "" {
+ return a.PaneID, true, nil
+ }
+ pane := strings.TrimSpace(ops.boundPane())
+ if pane == "" {
+ return "", false, nil
+ }
+ probe, err := ops.probePane(pane)
+ if err != nil {
+ return "", false, err
+ }
+ if !probe.Exists {
+ ops.clearBinding()
+ return "", false, nil
+ }
+ if probe.Busy || ops.boundMode() == bindModeShell {
+ return pane, true, nil
+ }
+ if ops.boundAge() > bindingLaunchGrace {
+ ops.reapPane(pane)
+ ops.clearBinding()
+ return "", false, nil
+ }
+ return pane, false, nil
+}
+
+// bindPlacement persists the placement herdr assigned this agent plus its
+// launch mode, so every later name-keyed op survives the name clear. Called
+// by Start after the agent (fresh or adopted) is up; Stop's clearMeta
+// removes it.
+func (p *Provider) bindPlacement(name string, info agentInfo, mode string) error {
+ for key, val := range map[string]string{
+ metaBoundPane: info.PaneID,
+ metaBoundTab: info.TabID,
+ metaBoundWorkspace: info.WorkspaceID,
+ metaBoundMode: mode,
+ metaBoundName: name,
+ metaBoundAt: strconv.FormatInt(time.Now().Unix(), 10),
+ } {
+ if val == "" {
+ continue
+ }
+ if err := p.SetMeta(name, key, val); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// clearPaneBinding drops the persisted placement (not the whole sidecar — the
+// session identity keys stay for the reconciler). Idempotent.
+func (p *Provider) clearPaneBinding(name string) {
+ _ = p.RemoveMeta(name, metaBoundPane)
+ _ = p.RemoveMeta(name, metaBoundTab)
+ _ = p.RemoveMeta(name, metaBoundWorkspace)
+ _ = p.RemoveMeta(name, metaBoundMode)
+ _ = p.RemoveMeta(name, metaBoundName)
+ _ = p.RemoveMeta(name, metaBoundAt)
+}
+
+// boundSessionNames enumerates the session names with a live-looking sidecar
+// binding (a stored name and pane id), for ListRunning to merge with herdr's
+// registry — which never sees raw shell sessions.
+func (p *Provider) boundSessionNames() []string {
+ entries, err := os.ReadDir(p.metaDir)
+ if err != nil {
+ return nil
+ }
+ var names []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ name, err := readMetaFile(filepath.Join(p.metaDir, e.Name(), sanitize(metaBoundName)))
+ if err != nil || name == "" {
+ continue
+ }
+ if pane, err := readMetaFile(filepath.Join(p.metaDir, e.Name(), sanitize(metaBoundPane))); err != nil || pane == "" {
+ continue
+ }
+ names = append(names, name)
+ }
+ return names
+}
+
+// readMetaFile reads one sidecar value ("" when absent).
+func readMetaFile(path string) (string, error) {
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(b)), nil
+}
+
+// probePane inspects a bound pane via `pane process-info`. herdr answering
+// not-found is a confirmed-gone (zero probe, nil error); any other failure is
+// a transport error that proves nothing.
+func (p *Provider) probePane(ctx context.Context, paneID string) (paneProbe, error) {
+ shellPID, fg, err := p.c.processInfo(ctx, paneID)
+ if err != nil {
+ if strings.Contains(err.Error(), "not_found") || strings.Contains(err.Error(), "not found") {
+ return paneProbe{}, nil
+ }
+ return paneProbe{}, err
+ }
+ return paneProbeFrom(shellPID, fg), nil
+}
+
+// interactiveShells are the interactive shells a fresh pane idles in; a pane
+// whose root foreground process is one of these (and nothing else runs) is at
+// a bare prompt.
+var interactiveShells = map[string]bool{
+ "sh": true, "bash": true, "zsh": true, "fish": true, "dash": true,
+ "ksh": true, "tcsh": true, "csh": true,
+}
+
+// paneProbeFrom folds process-info into the probe verdict. Busy means the
+// pane is running something beyond an interactive shell prompt: a foreground
+// process other than the root (a launched agent or a shell job), or a root
+// that is no longer a shell at all (`exec`'d commands replace it, keeping its
+// pid). This is the version-robust "is the session still in there" signal —
+// matching configured process names is not (claude ≥2.1.x reports comm as
+// its bare version string).
+func paneProbeFrom(shellPID int, fg []proc) paneProbe {
+ probe := paneProbe{Exists: shellPID != 0}
+ for _, pr := range fg {
+ if pr.PID == 0 {
+ continue
+ }
+ if pr.PID != shellPID || !interactiveShells[strings.TrimPrefix(pr.Name, "-")] {
+ probe.Busy = true
+ break
+ }
+ }
+ return probe
+}
+
+// paneRunsCommand reports whether a pane's foreground holds the launched
+// `/bin/sh -c ` wrapper (exec preserves argv) — the positive signal that
+// a typed raw launch actually executed, immune to the shell-init children a
+// fresh pane runs first.
+func paneRunsCommand(fg []proc, raw string) bool {
+ for _, pr := range fg {
+ if len(pr.Argv) >= 3 && strings.HasSuffix(pr.Argv[0], "sh") && pr.Argv[1] == "-c" && pr.Argv[2] == raw {
+ return true
+ }
+ }
+ return false
+}
+
+// paneRootReplaced reports whether the pane's root process (pid == shellPID)
+// is visible in the foreground and is no longer an interactive shell — a raw
+// launch that exec'd straight through the `/bin/sh -c` wrapper (e.g.
+// `exec sleep 120`). Shell-init children keep the root a shell, so they never
+// read as replaced.
+func paneRootReplaced(shellPID int, fg []proc) bool {
+ for _, pr := range fg {
+ if pr.PID == shellPID {
+ return !interactiveShells[strings.TrimPrefix(pr.Name, "-")]
+ }
+ }
+ return false
+}
+
+// lookupOps wires paneLookupOps for a session name.
+func (p *Provider) lookupOps(ctx context.Context, name string) paneLookupOps {
+ meta := func(key string) string {
+ v, err := p.GetMeta(name, key)
+ if err != nil {
+ return ""
+ }
+ return v
+ }
+ return paneLookupOps{
+ getAgent: func() (agentInfo, bool, error) { return p.c.getAgent(ctx, herdrAgentName(name)) },
+ boundPane: func() string { return meta(metaBoundPane) },
+ boundMode: func() string { return strings.TrimSpace(meta(metaBoundMode)) },
+ boundAge: func() time.Duration {
+ ts, err := strconv.ParseInt(strings.TrimSpace(meta(metaBoundAt)), 10, 64)
+ if err != nil || ts <= 0 {
+ return time.Duration(1<<62) * time.Nanosecond // unknown: treat as ancient (pre-upgrade binding)
+ }
+ return time.Since(time.Unix(ts, 0))
+ },
+ probePane: func(paneID string) (paneProbe, error) { return p.probePane(ctx, paneID) },
+ reapPane: func(paneID string) { _ = p.c.closePane(ctx, paneID) },
+ clearBinding: func() { p.clearPaneBinding(name) },
+ }
+}
diff --git a/internal/runtime/herdr/panebinding_live_test.go b/internal/runtime/herdr/panebinding_live_test.go
new file mode 100644
index 0000000000..d7c7f5dd68
--- /dev/null
+++ b/internal/runtime/herdr/panebinding_live_test.go
@@ -0,0 +1,78 @@
+package herdr
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+)
+
+// TestProviderLiveOccupantSwapKeepsLiveness models the herdr ≥0.7.4 breakage
+// against a real herdr binary: the agent's launch shell execs into a different
+// process (as claude's shell→TUI boot handoff replaces the pane occupant),
+// after which herdr may clear the agent's name from its registry. Whatever
+// this herdr version does to the name, the provider contract must hold: the
+// session stays running, a re-issued Start refuses with ErrSessionExists
+// (never a second placement — that was the spawn storm), and Stop still tears
+// the pane down. Skipped when herdr is unavailable or in -short mode.
+func TestProviderLiveOccupantSwapKeepsLiveness(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping live herdr test in -short mode")
+ }
+ if _, err := exec.LookPath("herdr"); err != nil {
+ t.Skip("herdr not installed")
+ }
+
+ p := New("gctest-swap", t.TempDir(), t.TempDir(), 0, 0)
+ _ = p.Stop("swap") // clear any leftover from a crashed prior run
+ t.Cleanup(func() { _ = p.Stop("swap"); _ = p.TeardownServer() })
+
+ ctx := context.Background()
+ cfg := runtime.Config{
+ WorkDir: t.TempDir(),
+ // The occupant swap: the launch shell replaces itself, mirroring the
+ // boot handoff that makes herdr ≥0.7.4 clear the agent's name.
+ Command: `exec sleep 120`,
+ Env: map[string]string{"GC_SESSION_ID": "gctest-swap-session"},
+ }
+ if err := p.Start(ctx, "swap", cfg); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ // Start must have persisted the pane binding — the only stable handle once
+ // the name clears.
+ if pane, err := p.GetMeta("swap", metaBoundPane); err != nil || pane == "" {
+ t.Fatalf("bound pane after Start = %q, %v; want non-empty", pane, err)
+ }
+
+ // Give the exec swap time to land, then hold liveness across several
+ // checks (the storm fired on every reconcile tick).
+ time.Sleep(2 * time.Second)
+ for i := 0; i < 3; i++ {
+ if !p.IsRunning("swap") {
+ t.Fatalf("IsRunning = false after occupant swap (check %d); this re-Start loop is the spawn storm", i)
+ }
+ if live := p.ObserveLiveness("swap", nil); !live.Running || !live.Alive {
+ t.Fatalf("ObserveLiveness = %+v after occupant swap (check %d); want Running=true Alive=true", live, i)
+ }
+ if err := p.Start(ctx, "swap", cfg); !errors.Is(err, runtime.ErrSessionExists) {
+ t.Fatalf("re-issued Start = %v (check %d); want ErrSessionExists", err, i)
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Stop must still find and close the pane (via the binding if the name is
+ // gone) — the pre-fix "sleep leak" left panes piling up here.
+ if err := p.Stop("swap"); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ for i := 0; i < 10 && p.IsRunning("swap"); i++ {
+ time.Sleep(200 * time.Millisecond)
+ }
+ if p.IsRunning("swap") {
+ t.Error("IsRunning = true after Stop")
+ }
+}
diff --git a/internal/runtime/herdr/panebinding_provider_test.go b/internal/runtime/herdr/panebinding_provider_test.go
new file mode 100644
index 0000000000..e9d0c4ae72
--- /dev/null
+++ b/internal/runtime/herdr/panebinding_provider_test.go
@@ -0,0 +1,432 @@
+package herdr
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+)
+
+// ── provider-level pane-binding behavior against a fake herdr 0.7.5 ──────────
+//
+// The fake herdr is a shell script modeling the ≥0.7.5 contract: `agent start`
+// launches a supported kind into an existing shell pane and registers the
+// name; the name exists only while the agent runs (state file "registered");
+// raw commands are typed into the pane and never register anything. State
+// files drive the scenario (registered / pane_gone / busy), and calls.log
+// records every verb so tests can assert what was — and crucially was NOT —
+// issued (the spawn storm was one placement per reconcile tick).
+
+var paneBindSession int64
+
+// newFakeHerdrProvider builds a Provider whose client shells out to a fake
+// herdr script. Returns the provider, its session name, and the state dir.
+func newFakeHerdrProvider(t *testing.T) (*Provider, string, string) {
+ t.Helper()
+ session := fmt.Sprintf("gctest-pb-%d-%d", os.Getpid(), atomic.AddInt64(&paneBindSession, 1))
+ state := t.TempDir()
+ metaDir := t.TempDir()
+ script := filepath.Join(t.TempDir(), "herdr")
+ fake := `#!/bin/sh
+STATE='` + state + `'
+METADIR='` + metaDir + `'
+shift 2
+printf '%s\n' "$*" >> "$STATE/calls.log"
+case "$1_$2" in
+agent_get)
+ if [ -e "$STATE/registered" ]; then
+ printf '%s' '{"result":{"agent":{"name":"'"$3"'","pane_id":"%5","tab_id":"t1","workspace_id":"w1","agent_status":"idle"}}}'
+ else
+ printf '%s' '{"error":{"code":"agent_not_found","message":"agent target not found"}}'
+ fi ;;
+agent_list)
+ printf '%s' '{"result":{"agents":[]}}' ;;
+agent_start)
+ : > "$STATE/agent_started"
+ : > "$STATE/registered"
+ if [ -e "$METADIR/$3/GC_SESSION_ID" ]; then : > "$STATE/meta_seeded_before_launch"; fi
+ if [ -e "$METADIR/$3/GC_HERDR_PANE_ID" ]; then : > "$STATE/bound_before_launch"; fi
+ printf '%s' '{"result":{"agent":{"name":"'"$3"'","pane_id":"%5","tab_id":"t1","workspace_id":"w1","agent_status":"idle"}}}' ;;
+agent_wait)
+ printf '%s' '{"result":{"agent":{"name":"'"$3"'","agent_status":"idle"}}}' ;;
+agent_prompt)
+ if [ -e "$STATE/registered" ]; then
+ : > "$STATE/prompted"
+ printf '%s' '{"result":{"type":"agent_prompted"}}'
+ else
+ printf '%s' '{"error":{"code":"agent_not_found","message":"agent target not found"}}'
+ fi ;;
+pane_run)
+ : > "$STATE/busy"
+ printf '%s' "$4" | sed -e 's|^exec /bin/sh -c ||' -e "s/^'//" -e "s/'\$//" > "$STATE/rawcmd"
+ exit 0 ;;
+pane_process-info)
+ if [ -e "$STATE/pane_gone" ]; then
+ printf '%s' '{"error":{"code":"pane_not_found","message":"pane not found"}}'
+ elif [ -e "$STATE/rawcmd" ]; then
+ printf '%s' '{"result":{"process_info":{"shell_pid":4242,"foreground_processes":[{"pid":4242,"name":"bash","argv":["/bin/sh","-c","'"$(cat "$STATE/rawcmd")"'"]}]}}}'
+ elif [ -e "$STATE/busy" ]; then
+ printf '%s' '{"result":{"process_info":{"shell_pid":4242,"foreground_processes":[{"pid":4243,"name":"claude"}]}}}'
+ else
+ printf '%s' '{"result":{"process_info":{"shell_pid":4242,"foreground_processes":[{"pid":4242,"name":"zsh"}]}}}'
+ fi ;;
+workspace_list)
+ : > "$STATE/placement_attempted"
+ printf '%s' '{"result":{"workspaces":[]}}' ;;
+workspace_create)
+ printf '%s' '{"result":{"workspace":{"workspace_id":"w1"},"tab":{"tab_id":"t1"},"root_pane":{"pane_id":"%5"}}}' ;;
+tab_list)
+ if [ -e "$STATE/stale_tabs" ]; then
+ printf '%s' '{"result":{"tabs":[{"tab_id":"t-old1","label":"witness"},{"tab_id":"t-old2","label":"witness"},{"tab_id":"t-other","label":"deacon"}]}}'
+ else
+ printf '%s' '{"result":{"tabs":[]}}'
+ fi ;;
+tab_create)
+ printf '%s' '{"result":{"tab":{"tab_id":"t1"},"root_pane":{"pane_id":"%5"}}}' ;;
+*)
+ exit 0 ;;
+esac
+`
+ if err := os.WriteFile(script, []byte(fake), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := New(session, metaDir, t.TempDir(), time.Second, time.Second)
+ p.c.bin = script
+ return p, session, state
+}
+
+// fakeCalls returns the verbs the fake herdr recorded.
+func fakeCalls(t *testing.T, state string) string {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join(state, "calls.log"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func setState(t *testing.T, state, flag string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(state, flag), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// listenHerdrSocket plants a live unix listener at the session's socket path so
+// ConfigureServer's serverAlive dial succeeds without launching a real server.
+func listenHerdrSocket(t *testing.T, session string) {
+ t.Helper()
+ home, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := filepath.Join(home, ".config", "herdr", "sessions", session)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ l, err := net.Listen("unix", filepath.Join(dir, "herdr.sock"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = l.Close()
+ _ = os.RemoveAll(dir)
+ })
+}
+
+// bindTestPane seeds the sidecar with the binding Start would have persisted
+// (the fake herdr always reports pane "%5").
+func bindTestPane(t *testing.T, p *Provider, name, mode string) {
+ t.Helper()
+ if err := p.SetMeta(name, metaBoundPane, "%5"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.SetMeta(name, metaBoundMode, mode); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The storm-killer: with no registry name but the bound pane busy running the
+// agent, IsRunning must stay true so the reconciler never re-issues Start.
+func TestIsRunningSurvivesNameClearViaPaneBinding(t *testing.T) {
+ p, _, state := newFakeHerdrProvider(t)
+ setState(t, state, "busy")
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+ if !p.IsRunning("gastown__witness") {
+ t.Fatal("IsRunning = false for a live agent whose name herdr cleared; this is the spawn-storm trigger")
+ }
+}
+
+// Without a binding, an unregistered name is a genuinely absent session.
+func TestIsRunningFalseWhenNameClearedAndNoBinding(t *testing.T) {
+ p, _, _ := newFakeHerdrProvider(t)
+ if p.IsRunning("gastown__witness") {
+ t.Fatal("IsRunning = true with no live name and no pane binding")
+ }
+}
+
+// An exited agent — pane back at its bare shell prompt — is NOT running, so
+// the reconciler can restart it; a bare-shell session in the same pane state
+// IS running (the shell is the session).
+func TestIsRunningModeAwareAtShellPrompt(t *testing.T) {
+ p, _, _ := newFakeHerdrProvider(t)
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+ if p.IsRunning("gastown__witness") {
+ t.Fatal("IsRunning = true for an exited agent (pane at shell prompt); restarts would never happen")
+ }
+ bindTestPane(t, p, "gastown__shellsess", bindModeShell)
+ if !p.IsRunning("gastown__shellsess") {
+ t.Fatal("IsRunning = false for a bare-shell session whose pane exists")
+ }
+}
+
+// Start on a live-but-unregistered session must return ErrSessionExists
+// WITHOUT touching placement: each wrongful placement leaked a pane, which is
+// the unbounded shell storm.
+func TestStartReturnsSessionExistsWithoutPlacementWhenNameCleared(t *testing.T) {
+ p, session, state := newFakeHerdrProvider(t)
+ listenHerdrSocket(t, session)
+ setState(t, state, "busy")
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+
+ err := p.Start(context.Background(), "gastown__witness", runtime.Config{})
+ if !errors.Is(err, runtime.ErrSessionExists) {
+ t.Fatalf("Start = %v; want ErrSessionExists", err)
+ }
+ calls := fakeCalls(t, state)
+ if strings.Contains(calls, "workspace") || strings.Contains(calls, "agent start") {
+ t.Fatalf("Start touched placement/spawn for a live session (the storm):\n%s", calls)
+ }
+}
+
+// A clean claude command takes the ≥0.7.5 kind-launch path: placement creates
+// the shell pane (with cwd baked in), `agent start --kind claude --pane`
+// launches into it, and the binding + agent mode are persisted.
+func TestStartKindPathRegistersAndPersistsBinding(t *testing.T) {
+ p, session, state := newFakeHerdrProvider(t)
+ listenHerdrSocket(t, session)
+
+ cfg := runtime.Config{
+ Command: "claude --dangerously-skip-permissions",
+ Env: map[string]string{"GC_SESSION_ID": "sess-1", "GC_INSTANCE_TOKEN": "tok-1"},
+ }
+ if err := p.Start(context.Background(), "gastown__witness", cfg); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ calls := fakeCalls(t, state)
+ if !strings.Contains(calls, "agent start gastown__witness --kind claude --pane %5") {
+ t.Fatalf("Start did not kind-launch into the placed pane:\n%s", calls)
+ }
+ // The kind launch blocks for seconds (readiness wait + TUI detection), so
+ // the identity sidecar AND a provisional pane binding must exist BEFORE
+ // the launch: reconcile ticks that fire mid-boot read them, and an
+ // unseeded sidecar makes the ownership check roll the fresh runtime back
+ // ("live runtime belongs to another session").
+ if _, err := os.Stat(filepath.Join(state, "meta_seeded_before_launch")); err != nil {
+ t.Error("GC_SESSION_ID was not in the sidecar before the agent launch")
+ }
+ if _, err := os.Stat(filepath.Join(state, "bound_before_launch")); err != nil {
+ t.Error("pane binding was not persisted before the agent launch")
+ }
+ if got, _ := p.GetMeta("gastown__witness", metaBoundPane); got != "%5" {
+ t.Fatalf("bound pane after Start = %q; want %%5", got)
+ }
+ if got, _ := p.GetMeta("gastown__witness", metaBoundMode); got != bindModeAgent {
+ t.Fatalf("bound mode after Start = %q; want %q", got, bindModeAgent)
+ }
+}
+
+// A non-kind command is exec'd through the pane shell (raw path): no herdr
+// agent registration, shell mode persisted, pane still the session handle.
+func TestStartRawPathExecsThroughPaneShell(t *testing.T) {
+ p, session, state := newFakeHerdrProvider(t)
+ listenHerdrSocket(t, session)
+
+ cfg := runtime.Config{Command: "python3 worker.py --queue main"}
+ if err := p.Start(context.Background(), "gastown__worker", cfg); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ calls := fakeCalls(t, state)
+ if !strings.Contains(calls, "pane run %5 exec /bin/sh -c ") {
+ t.Fatalf("Start did not exec the raw command through the pane shell:\n%s", calls)
+ }
+ if strings.Contains(calls, "agent start") {
+ t.Fatalf("raw command must not attempt a kind launch:\n%s", calls)
+ }
+ if got, _ := p.GetMeta("gastown__worker", metaBoundMode); got != bindModeShell {
+ t.Fatalf("bound mode after raw Start = %q; want %q", got, bindModeShell)
+ }
+}
+
+// gc session names carrying uppercase rig names must launch under their
+// mapped herdr agent name (herdr ≥0.7.5 rejects them verbatim with
+// invalid_agent_name — a hot retry loop found live), while the sidecar keeps
+// the exact gc name for enumeration.
+func TestStartMapsSessionNameToValidHerdrName(t *testing.T) {
+ p, session, state := newFakeHerdrProvider(t)
+ listenHerdrSocket(t, session)
+
+ if err := p.Start(context.Background(), "Indigo--anthony", runtime.Config{Command: "claude"}); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ calls := fakeCalls(t, state)
+ if !strings.Contains(calls, "agent start indigo--anthony --kind claude") {
+ t.Fatalf("Start did not use the mapped herdr agent name:\n%s", calls)
+ }
+ if strings.Contains(calls, "agent start Indigo--anthony") {
+ t.Fatalf("Start used the raw gc name herdr rejects:\n%s", calls)
+ }
+ if got, _ := p.GetMeta("Indigo--anthony", metaBoundName); got != "Indigo--anthony" {
+ t.Fatalf("sidecar name = %q; want the exact gc name", got)
+ }
+ // Liveness and enumeration still key on the gc name.
+ if !p.IsRunning("Indigo--anthony") {
+ t.Fatal("IsRunning(gc name) = false for the running mapped agent")
+ }
+ if names, err := p.ListRunning("Indigo"); err != nil || len(names) != 1 || names[0] != "Indigo--anthony" {
+ t.Fatalf("ListRunning = %v, %v; want [Indigo--anthony]", names, err)
+ }
+}
+
+// Placement must recycle EVERY stale tab carrying the session's label, not
+// just the first: reconciler churn can leave several behind, and a survivor
+// lingers forever (its shell pane with it).
+func TestStartRecyclesAllStaleTabs(t *testing.T) {
+ p, session, state := newFakeHerdrProvider(t)
+ listenHerdrSocket(t, session)
+ setState(t, state, "stale_tabs")
+ // An existing workspace forces the findTab path (workspace list must hit).
+ oldWorkspaceList := "workspace_list)\n : > \"$STATE/placement_attempted\"\n printf '%s' '{\"result\":{\"workspaces\":[]}}' ;;"
+ newWorkspaceList := "workspace_list)\n printf '%s' '{\"result\":{\"workspaces\":[{\"workspace_id\":\"w1\",\"label\":\"gastown\"}]}}' ;;"
+ rewriteFake(t, p, oldWorkspaceList, newWorkspaceList)
+
+ if err := p.Start(context.Background(), "gastown__witness", runtime.Config{Command: "claude"}); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ calls := fakeCalls(t, state)
+ for _, tab := range []string{"tab close t-old1", "tab close t-old2"} {
+ if !strings.Contains(calls, tab) {
+ t.Errorf("stale duplicate not recycled (%s missing):\n%s", tab, calls)
+ }
+ }
+ if strings.Contains(calls, "tab close t-other") {
+ t.Errorf("closed another session's tab:\n%s", calls)
+ }
+}
+
+// rewriteFake patches the fake herdr script in place.
+func rewriteFake(t *testing.T, p *Provider, old, replacement string) {
+ t.Helper()
+ b, err := os.ReadFile(p.c.bin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ patched := strings.Replace(string(b), old, replacement, 1)
+ if patched == string(b) {
+ t.Fatalf("fake script pattern not found:\n%s", old)
+ }
+ if err := os.WriteFile(p.c.bin, []byte(patched), 0o755); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Stop must still close the pane via the sidecar binding when no registry
+// name exists (the earlier "sleep leak": name lost ⇒ pane never found ⇒
+// closePane never issued ⇒ panes piled up), even for an exited agent whose
+// pane idles at a prompt — and clear the sidecar.
+func TestStopClosesPaneViaBindingWhenNameCleared(t *testing.T) {
+ p, _, state := newFakeHerdrProvider(t)
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+
+ if err := p.Stop("gastown__witness"); err != nil {
+ t.Fatalf("Stop: %v", err)
+ }
+ if calls := fakeCalls(t, state); !strings.Contains(calls, "pane close %5") {
+ t.Fatalf("Stop never closed the bound pane:\n%s", calls)
+ }
+ if got, _ := p.GetMeta("gastown__witness", metaBoundPane); got != "" {
+ t.Fatalf("binding survived Stop: %q", got)
+ }
+}
+
+// ObserveLiveness is the fast path every liveness consumer actually reads; it
+// must fall back to the bound pane too, or the reconciler still sees
+// Running=false each tick and drives Start.
+func TestObserveLivenessFallsBackToBoundPane(t *testing.T) {
+ p, _, state := newFakeHerdrProvider(t)
+ setState(t, state, "busy")
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+
+ if got := p.ObserveLiveness("gastown__witness", nil); !got.Running || !got.Alive {
+ t.Fatalf("ObserveLiveness = %+v; want Running=true Alive=true via bound pane", got)
+ }
+
+ // Pane confirmed gone: liveness zero and the stale binding is cleared so a
+ // recycled pane id can never resurrect a dead session.
+ setState(t, state, "pane_gone")
+ if got := p.ObserveLiveness("gastown__witness", nil); got.Running || got.Alive {
+ t.Fatalf("ObserveLiveness = %+v for a gone pane; want zero", got)
+ }
+ if got, _ := p.GetMeta("gastown__witness", metaBoundPane); got != "" {
+ t.Fatalf("confirmed-gone binding survived: %q", got)
+ }
+}
+
+// An exited agent (pane at bare prompt, agent mode) reads as not running so
+// the reconciler restarts it.
+func TestObserveLivenessExitedAgentReadsDead(t *testing.T) {
+ p, _, _ := newFakeHerdrProvider(t)
+ bindTestPane(t, p, "gastown__witness", bindModeAgent)
+ if got := p.ObserveLiveness("gastown__witness", nil); got.Running || got.Alive {
+ t.Fatalf("ObserveLiveness = %+v for an exited agent; want zero", got)
+ }
+}
+
+// ListRunning must see sessions that herdr's registry does not: raw shell
+// sessions never register an agent, so listing by registry alone hides them
+// from every session-enumeration consumer (orphan detection, gc ls).
+func TestListRunningIncludesUnregisteredBoundSessions(t *testing.T) {
+ p, _, state := newFakeHerdrProvider(t)
+ setState(t, state, "busy")
+ for _, name := range []string{"gastown__worker-1", "gastown__worker-2", "other__worker"} {
+ bindTestPane(t, p, name, bindModeShell)
+ if err := p.SetMeta(name, metaBoundName, name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := p.ListRunning("gastown__")
+ if err != nil {
+ t.Fatalf("ListRunning: %v", err)
+ }
+ want := map[string]bool{"gastown__worker-1": true, "gastown__worker-2": true}
+ if len(got) != len(want) {
+ t.Fatalf("ListRunning = %v; want exactly %v", got, want)
+ }
+ for _, n := range got {
+ if !want[n] {
+ t.Fatalf("ListRunning = %v; unexpected %q", got, n)
+ }
+ }
+}
+
+// A bound session whose pane is gone must not be listed (and is pruned).
+func TestListRunningSkipsGonePanes(t *testing.T) {
+ p, _, state := newFakeHerdrProvider(t)
+ setState(t, state, "pane_gone")
+ bindTestPane(t, p, "gastown__worker-1", bindModeShell)
+ if err := p.SetMeta("gastown__worker-1", metaBoundName, "gastown__worker-1"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := p.ListRunning("gastown__")
+ if err != nil || len(got) != 0 {
+ t.Fatalf("ListRunning = %v, %v; want empty", got, err)
+ }
+}
diff --git a/internal/runtime/herdr/panebinding_test.go b/internal/runtime/herdr/panebinding_test.go
new file mode 100644
index 0000000000..25c5a73288
--- /dev/null
+++ b/internal/runtime/herdr/panebinding_test.go
@@ -0,0 +1,236 @@
+package herdr
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+// ── resolveBinding: two-tier name→pane resolution + running verdict ──────────
+//
+// herdr ≥0.7.4 clears an agent's *name* when its pane occupant changes, so
+// name-keyed lookups can go dark on a live agent. resolveBinding keeps the
+// name lookup as the fast path and falls back to the pane binding Start
+// persisted in the sidecar, probed live before it is trusted (pane ids
+// recycle). The running verdict is mode-aware: a registered agent
+// (bindModeAgent) whose pane sits at a bare shell prompt past the launch
+// grace has *exited* and is REAPED (pane closed, binding cleared) — under
+// tmux the pane would have died with the process; a raw shell session
+// (bindModeShell) is running as long as its pane exists, because
+// `exec /bin/sh -c …` panes die with the command.
+
+// resolveOpsRec records the side effects resolveBinding performed.
+type resolveOpsRec struct {
+ cleared bool
+ reaped string
+}
+
+func opsForRec(t *testing.T, agentHit bool, agentErr error, bound, mode string, probe paneProbe, probeErr error, rec *resolveOpsRec) paneLookupOps {
+ t.Helper()
+ return paneLookupOps{
+ getAgent: func() (agentInfo, bool, error) {
+ if agentErr != nil {
+ return agentInfo{}, false, agentErr
+ }
+ if agentHit {
+ return agentInfo{Name: "mayor", PaneID: "%5"}, true, nil
+ }
+ return agentInfo{}, false, nil
+ },
+ boundPane: func() string { return bound },
+ boundMode: func() string { return mode },
+ boundAge: func() time.Duration { return time.Hour }, // long past any launch window
+ probePane: func(string) (paneProbe, error) { return probe, probeErr },
+ reapPane: func(paneID string) { rec.reaped = paneID },
+ clearBinding: func() { rec.cleared = true },
+ }
+}
+
+func opsFor(t *testing.T, agentHit bool, agentErr error, bound, mode string, probe paneProbe, probeErr error, cleared *bool) paneLookupOps {
+ t.Helper()
+ rec := &resolveOpsRec{}
+ ops := opsForRec(t, agentHit, agentErr, bound, mode, probe, probeErr, rec)
+ if cleared != nil {
+ ops.clearBinding = func() { *cleared = true }
+ }
+ return ops
+}
+
+func TestResolveBindingNameHitWinsAndRuns(t *testing.T) {
+ cleared := false
+ ops := opsFor(t, true, nil, "", "", paneProbe{}, nil, &cleared)
+ ops.boundPane = func() string { t.Fatal("bound pane must not be consulted on a name hit"); return "" }
+ ops.probePane = func(string) (paneProbe, error) { t.Fatal("no probe on a name hit"); return paneProbe{}, nil }
+ pane, running, err := resolveBinding(ops)
+ if err != nil || pane != "%5" || !running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want %%5, true, nil", pane, running, err)
+ }
+ if cleared {
+ t.Error("binding cleared on a name hit")
+ }
+}
+
+// The 0.7.4 storm case: name cleared, bound pane busy running the agent.
+func TestResolveBindingBusyPaneRunsRegardlessOfMode(t *testing.T) {
+ for _, mode := range []string{bindModeAgent, bindModeShell, ""} {
+ pane, running, err := resolveBinding(opsFor(t, false, nil, "%5", mode, paneProbe{Exists: true, Busy: true}, nil, nil))
+ if err != nil || pane != "%5" || !running {
+ t.Fatalf("mode %q: resolveBinding = %q, %v, %v; want %%5, true, nil", mode, pane, running, err)
+ }
+ }
+}
+
+// A registered agent's pane back at its bare shell prompt past the launch
+// grace means the agent EXITED: under tmux the pane would have died with the
+// process, so reap it — close the pane, clear the binding, resolve absent.
+// Without this, every completed ephemeral wisp (unique tab label, no future
+// Start to recycle it, no Stop because the session reads not-running) leaks
+// one shell pane forever — the herdr echo of the witness sleep leak.
+func TestResolveBindingReapsExitedAgentPane(t *testing.T) {
+ rec := &resolveOpsRec{}
+ pane, running, err := resolveBinding(opsForRec(t, false, nil, "%5", bindModeAgent, paneProbe{Exists: true, Busy: false}, nil, rec))
+ if err != nil || pane != "" || running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want absent (exited agent reaped)", pane, running, err)
+ }
+ if rec.reaped != "%5" {
+ t.Errorf("exited agent pane not reaped (reaped=%q)", rec.reaped)
+ }
+ if !rec.cleared {
+ t.Error("exited agent binding not cleared")
+ }
+}
+
+// Inside the launch grace window the same pane state means "shell ready,
+// agent still being launched": the pane must resolve untouched — a reap here
+// would close the pane out from under the in-flight Start that provisionally
+// bound it.
+func TestResolveBindingSparesFreshBindingAtPrompt(t *testing.T) {
+ rec := &resolveOpsRec{}
+ ops := opsForRec(t, false, nil, "%5", bindModeAgent, paneProbe{Exists: true, Busy: false}, nil, rec)
+ ops.boundAge = func() time.Duration { return 5 * time.Second }
+ pane, running, err := resolveBinding(ops)
+ if err != nil || pane != "%5" || running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want %%5, false, nil (mid-launch pane spared)", pane, running, err)
+ }
+ if rec.reaped != "" || rec.cleared {
+ t.Error("mid-launch pane was reaped/cleared")
+ }
+}
+
+// A bare-shell session (empty command) is its own shell: running while the
+// pane exists even with nothing in the foreground.
+func TestResolveBindingShellModeExistsIsRunning(t *testing.T) {
+ pane, running, err := resolveBinding(opsFor(t, false, nil, "%5", bindModeShell, paneProbe{Exists: true, Busy: false}, nil, nil))
+ if err != nil || pane != "%5" || !running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want %%5, true, nil", pane, running, err)
+ }
+}
+
+// A pane herdr confirms gone is a stale binding: absent, not running, cleared.
+func TestResolveBindingClearsConfirmedGonePane(t *testing.T) {
+ cleared := false
+ pane, running, err := resolveBinding(opsFor(t, false, nil, "%5", bindModeAgent, paneProbe{}, nil, &cleared))
+ if err != nil || pane != "" || running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want absent", pane, running, err)
+ }
+ if !cleared {
+ t.Error("confirmed-gone binding was not cleared")
+ }
+}
+
+// A transport failure probing the pane proves nothing: surface the error,
+// keep the binding — a socket blip must not erase the handle to a live agent.
+func TestResolveBindingProbeTransportErrorKeepsBinding(t *testing.T) {
+ cleared := false
+ blip := errors.New("dial unix: connection refused")
+ pane, running, err := resolveBinding(opsFor(t, false, nil, "%5", bindModeShell, paneProbe{}, blip, &cleared))
+ if !errors.Is(err, blip) || pane != "" || running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want the probe error", pane, running, err)
+ }
+ if cleared {
+ t.Error("binding cleared on a transport error")
+ }
+}
+
+// No binding and no live name: genuinely absent.
+func TestResolveBindingAbsentWithoutBinding(t *testing.T) {
+ ops := opsFor(t, false, nil, "", "", paneProbe{}, nil, nil)
+ ops.probePane = func(string) (paneProbe, error) { t.Fatal("no binding, no probe"); return paneProbe{}, nil }
+ pane, running, err := resolveBinding(ops)
+ if err != nil || pane != "" || running {
+ t.Fatalf("resolveBinding = %q, %v, %v; want absent", pane, running, err)
+ }
+}
+
+// ── paneProbeFrom: the busy verdict ──────────────────────────────────────────
+
+func TestPaneProbeFrom(t *testing.T) {
+ tests := []struct {
+ name string
+ shellPID int
+ fg []proc
+ want paneProbe
+ }{
+ {"gone", 0, nil, paneProbe{}},
+ {"bare prompt (root shell only)", 100, []proc{{PID: 100, Name: "zsh"}}, paneProbe{Exists: true}},
+ {"bare prompt, login-shell name", 100, []proc{{PID: 100, Name: "-zsh"}}, paneProbe{Exists: true}},
+ {"empty foreground", 100, nil, paneProbe{Exists: true}},
+ {"foreground child (launched agent)", 100, []proc{{PID: 101, Name: "claude"}}, paneProbe{Exists: true, Busy: true}},
+ {"exec'd command replaced the shell", 100, []proc{{PID: 100, Name: "sleep"}}, paneProbe{Exists: true, Busy: true}},
+ {"sh -c wrapper with child", 100, []proc{{PID: 101, Name: "sleep"}, {PID: 100, Name: "bash"}}, paneProbe{Exists: true, Busy: true}},
+ }
+ for _, tt := range tests {
+ if got := paneProbeFrom(tt.shellPID, tt.fg); got != tt.want {
+ t.Errorf("%s: paneProbeFrom = %+v; want %+v", tt.name, got, tt.want)
+ }
+ }
+}
+
+// paneRunsCommand recognizes the launched `/bin/sh -c ` in a pane's
+// foreground — the signal that the typed launch actually executed (a fresh
+// pane's shell-init children read as Busy, so Busy alone cannot tell "our
+// command is running" from "zsh is still sourcing rc files").
+func TestPaneRunsCommand(t *testing.T) {
+ raw := `for i in $(seq 1 60); do echo "tick $i"; sleep 1; done`
+ wrapper := proc{PID: 100, Name: "bash", Argv: []string{"/bin/sh", "-c", raw}}
+ if !paneRunsCommand([]proc{{PID: 101, Name: "sleep"}, wrapper}, raw) {
+ t.Error("wrapper present: want true")
+ }
+ init := []proc{{PID: 100, Name: "zsh", Argv: []string{"-zsh"}}, {PID: 102, Name: "sw_vers", Argv: []string{"/usr/bin/sw_vers"}}}
+ if paneRunsCommand(init, raw) {
+ t.Error("shell-init foreground must not read as launched")
+ }
+ if paneRunsCommand(nil, raw) {
+ t.Error("empty foreground must not read as launched")
+ }
+}
+
+// paneRootReplaced spots a launch whose command exec'd straight through the
+// wrapper (e.g. `exec sleep 120`): the pane's root pid is no longer a shell.
+// Shell-init children (root still a shell) must not read as replaced.
+func TestPaneRootReplaced(t *testing.T) {
+ if !paneRootReplaced(100, []proc{{PID: 100, Name: "sleep"}}) {
+ t.Error("exec'd root: want replaced")
+ }
+ if paneRootReplaced(100, []proc{{PID: 100, Name: "-zsh"}, {PID: 102, Name: "sw_vers"}}) {
+ t.Error("shell init: want not replaced")
+ }
+ if paneRootReplaced(100, nil) {
+ t.Error("no root visible: want not replaced")
+ }
+}
+
+// A name-lookup transport failure surfaces without touching the binding.
+func TestResolveBindingNameLookupErrorSurfaces(t *testing.T) {
+ cleared := false
+ boom := errors.New("herdr transport down")
+ ops := opsFor(t, false, boom, "%5", bindModeAgent, paneProbe{Exists: true, Busy: true}, nil, &cleared)
+ ops.boundPane = func() string { t.Fatal("no fallback on a name-lookup transport error"); return "" }
+ _, running, err := resolveBinding(ops)
+ if !errors.Is(err, boom) || running {
+ t.Fatalf("resolveBinding = _, %v, %v; want the lookup error", running, err)
+ }
+ if cleared {
+ t.Error("binding cleared on a name-lookup transport error")
+ }
+}
diff --git a/internal/runtime/herdr/provider.go b/internal/runtime/herdr/provider.go
index 10407e2268..9726464a4b 100644
--- a/internal/runtime/herdr/provider.go
+++ b/internal/runtime/herdr/provider.go
@@ -95,41 +95,100 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
// Place the agent in its own tab under a per-rig (per-town) workspace, so
// agents are separate switchable spaces rather than tiled panes. The
// find-or-create is serialized so concurrent same-rig Starts share one
- // workspace instead of racing to create duplicates.
+ // workspace instead of racing to create duplicates. Under herdr ≥0.7.5 the
+ // tab's root shell pane — created here with the agent's cwd and env — IS
+ // the agent's pane.
wsLabel, tabLabel := placementFor(name, cfg.Env)
p.mu.Lock()
- tabID, strayPane, err := p.c.ensurePlacement(ctx, wsLabel, tabLabel)
+ tabID, paneID, err := p.c.ensurePlacement(ctx, wsLabel, tabLabel, effectiveWorkDir(cfg, p.c.cityRoot), cfg.Env)
p.mu.Unlock()
if err != nil {
return fmt.Errorf("herdr: place %q: %w", name, err)
}
- info, err := p.c.startAgent(ctx, name, tabID, effectiveWorkDir(cfg, p.c.cityRoot), cfg.Env, shellArgv(cfg.Command))
- if err != nil {
- return fmt.Errorf("herdr: start %q: %w", name, err)
- }
- // Seed the metadata sidecar from cfg.Env NOW, before the (long) startup
- // delivery below. tmux gets this for free — its GetMeta reads the tmux
- // session environment, which new-session initializes from cfg.Env — but
- // herdr's meta store is a sidecar populated only by SetMeta. The reconciler's
- // pending-create ownership check (runningSessionMatchesPendingCreateInfo)
- // reads GC_SESSION_ID / GC_INSTANCE_TOKEN via GetMeta on ticks that fire
- // while Start is still waiting for the agent to idle; with an unseeded
- // sidecar it misreads the fresh runtime as "live runtime belongs to another
- // session" and reaps it seconds after a successful start.
- //
- // Seeding the whole env also persists GC_SESSION_ID, which ProcessAlive's
- // session-scoped tree-walk widening reads (herdr does not capture the
- // creation environment the way tmux does): process env survives reparenting
- // (only ppid changes), so this is what lets the walk find the agent when it
- // is no longer a descendant of the pane's shell/foreground PIDs. Stop clears
- // the whole meta dir, so teardown is covered.
+ spec := launchSpecFor(cfg.Command)
+ info := agentInfo{PaneID: paneID, TabID: tabID}
+ adopted := false
+ mode := bindModeShell
+ if spec.Kind != "" {
+ mode = bindModeAgent
+ }
+ // Seed the metadata sidecar from cfg.Env and persist a provisional pane
+ // binding BEFORE the launch. The launch below blocks for seconds (shell
+ // readiness + herdr's TUI detection), and reconcile ticks that fire in
+ // that window read both stores: the pending-create ownership check
+ // (runningSessionMatchesPendingCreateInfo) reads GC_SESSION_ID /
+ // GC_INSTANCE_TOKEN via GetMeta — with an unseeded sidecar it misreads
+ // the fresh runtime as "live runtime belongs to another session" and
+ // rolls it back mid-boot — and liveness reads the pane binding. tmux gets
+ // the env half for free (its GetMeta reads the session environment, which
+ // new-session initializes from cfg.Env); herdr's sidecar is populated
+ // only by SetMeta. Seeding the whole env also persists GC_SESSION_ID for
+ // ProcessAlive's session-scoped tree-walk widening (process env survives
+ // reparenting). Stop clears the whole meta dir, so teardown is covered,
+ // including a launch that fails below.
if err := p.seedMetaFromEnv(name, cfg.Env); err != nil {
return fmt.Errorf("herdr: seed session metadata for %q: %w", name, err)
}
- // herdr auto-spawns a stray shell pane when it creates a workspace/tab; close
- // it so the tab holds only the agent.
- if strayPane != "" && strayPane != info.PaneID {
- _ = p.c.closePane(ctx, strayPane)
+ if err := p.bindPlacement(name, info, mode); err != nil {
+ return fmt.Errorf("herdr: persist pane binding for %q: %w", name, err)
+ }
+ // Launch. herdr ≥0.7.5's `agent start` launches a supported agent kind's
+ // canonical executable into the shell pane and blocks until the TUI is
+ // detected (native claude-detection); commands that aren't a clean kind
+ // invocation are exec'd through the pane's shell instead, so the pane
+ // still dies with the command. On agent_name_taken (a concurrent Start
+ // won the name), adopt the live holder or reap a stale one and retry once
+ // — never loop placement, which is the pane/PTY/process storm.
+ switch {
+ case spec.Kind != "":
+ // herdr requires the target pane to be "an available shell" — a
+ // fresh pane's shell spends its first moments sourcing rc files
+ // (agent_pane_busy otherwise), so wait for the prompt, then retry a
+ // residual busy rejection briefly.
+ p.waitPaneShellReady(ctx, paneID)
+ for attempt := 0; ; attempt++ {
+ info, adopted, err = p.startAgentAdopting(ctx, name, spec.Kind, paneID, spec.Args)
+ if err == nil || herdrErrorCode(err) != "agent_pane_busy" || attempt >= paneBusyRetries {
+ break
+ }
+ // Back off before re-probing: herdr's own shell-prompt detection
+ // lags the process-table probe on a fresh pane, so an immediate
+ // retry burns the attempt against the same stale verdict.
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("herdr: start %q: %w", name, ctx.Err())
+ case <-time.After(time.Second << attempt):
+ }
+ p.waitPaneShellReady(ctx, paneID)
+ }
+ if err == nil && adopted && info.PaneID != "" && info.PaneID != paneID {
+ // Adopted a live holder elsewhere: the fresh pane placed above is
+ // surplus — close it (with its tab) or it leaks one shell per adopt.
+ _ = p.c.tabClose(ctx, tabID)
+ }
+ case spec.Raw != "":
+ // exec through the shell so the pane's root process becomes the
+ // command: when it exits the pane (and tab) close, preserving the
+ // tmux contract that a session ends with its command. The typed
+ // command executes only after the fresh pane's shell finishes
+ // initializing, so wait (bounded) for the launch to actually land —
+ // otherwise callers probing right after Start see a bare shell.
+ if err = p.c.paneRun(ctx, paneID, "exec /bin/sh -c "+shellquote.Quote(spec.Raw)); err == nil {
+ p.waitPaneLaunched(ctx, paneID, spec.Raw)
+ }
+ default:
+ // Empty command: the pane's own shell is the session.
+ }
+ if err != nil {
+ return fmt.Errorf("herdr: start %q: %w", name, err)
+ }
+ // Re-persist the binding with the launch's final placement: adoption may
+ // have landed on the live holder's pane rather than the one placed above.
+ // This binding is what keeps IsRunning/paneID resolving the session when
+ // no registry name exists — herdr ≥0.7.4 clears names on occupant change,
+ // and raw/bare-shell sessions never register one (see panebinding.go).
+ if err := p.bindPlacement(name, info, mode); err != nil {
+ return fmt.Errorf("herdr: persist pane binding for %q: %w", name, err)
}
// Deliver the agent's first turn. Two independent sources, mirroring tmux:
// a named always-awake Claude session carries its behavioral prime in
@@ -143,7 +202,10 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
// returns prime-then-nudge when both are set; a pool slot's claim nudge is
// returned unchanged. Route it through the one hardened post-idle
// paste+submit path. See startupDeliveryText.
- if startupText := startupDeliveryText(cfg); startupText != "" && info.PaneID != "" {
+ // Skip delivery when we adopted an already-running holder: it is a live,
+ // already-primed agent, and re-delivering would inject the startup prime into
+ // a working session.
+ if startupText := startupDeliveryText(cfg); !adopted && startupText != "" && info.PaneID != "" {
// A freshly-spawned agent boots through a shell→TUI handoff before its
// input prompt is listening. The paste buffers and survives that window,
// but the submit CR does not: delivered too early it is swallowed, leaving
@@ -155,7 +217,7 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
// worse than the prior unconditional send), and the reconciler tolerates a
// slow Start (pendingCreateNeverStartedTimeout = 10m).
_ = p.WaitForIdle(ctx, name, startupNudgeIdleTimeout)
- if err := p.c.deliverNudge(ctx, info.PaneID, name, startupText); err != nil {
+ if err := p.c.deliverNudge(ctx, info.PaneID, startupText); err != nil {
// Best-effort: the submit didn't confirm (TUI race under boot load).
// Surface it rather than silently leaving a stranded startup turn;
// nudgeStalledPoolClaims is the reconcile-tick backstop of last resort.
@@ -330,13 +392,15 @@ func (p *Provider) runSetupCommand(ctx context.Context, cmd string, env map[stri
}
// Stop closes the agent's pane and clears its metadata sidecar. Idempotent.
+// The pane resolves through the sidecar binding when the name is gone — the
+// earlier "sleep leak" was exactly this gap: name lost ⇒ pane never found ⇒
+// closePane never issued ⇒ panes piled up across witness sleep cycles.
func (p *Provider) Stop(name string) error {
ctx := context.Background()
pid, err := p.paneID(ctx, name)
- if err != nil || pid == "" {
- return nil // idempotent
+ if err == nil && pid != "" {
+ _ = p.c.closePane(ctx, pid)
}
- _ = p.c.closePane(ctx, pid)
_ = p.clearMeta(name)
return nil
}
@@ -351,18 +415,15 @@ func (p *Provider) Interrupt(name string) error {
return p.c.sendKeys(ctx, pid, "ctrl+c") // herdr has no signal API; ctrl+c is the soft interrupt
}
-// IsRunning reports whether an agent with this name exists in the session.
+// IsRunning reports whether the agent's session is running: its name is live
+// in herdr's registry OR its bound pane still runs its session (raw sessions
+// never register a name; herdr ≥0.7.4 clears names on occupant change — a
+// name-only check re-Starts live sessions every tick: the spawn storm). An
+// exited agent whose pane idles at a shell prompt is NOT running, so
+// restarts still happen.
func (p *Provider) IsRunning(name string) bool {
- agents, err := p.c.listAgents(context.Background())
- if err != nil {
- return false
- }
- for _, a := range agents {
- if a.Name == name {
- return true
- }
- }
- return false
+ _, running, err := resolveBinding(p.lookupOps(context.Background(), name))
+ return err == nil && running
}
// IsAttached reports false: herdr 0.7.1 exposes no clean attach-state query.
@@ -370,7 +431,7 @@ func (p *Provider) IsAttached(_ string) bool { return false }
// Attach runs `herdr agent attach`, blocking until the user detaches.
func (p *Provider) Attach(name string) error {
- cmd := exec.Command(p.c.bin, "--session", p.c.session, "agent", "attach", name)
+ cmd := exec.Command(p.c.bin, "--session", p.c.session, "agent", "attach", herdrAgentName(name))
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run() // blocks until the user detaches
}
@@ -394,7 +455,19 @@ func (p *Provider) ProcessAlive(name string, processNames []string) bool {
if err != nil || pid == "" {
return false
}
- shellPID, fg, err := p.c.processInfo(ctx, pid)
+ return p.processAliveByPane(ctx, name, pid, processNames)
+}
+
+// processAliveByPane reports whether the process tree rooted at paneID runs one
+// of processNames. It is the shared core of ProcessAlive and the adopt decision
+// in Start: ProcessAlive resolves the pane from the session name, while the
+// adopt path already holds the contested holder's pane id. The session-scoped
+// tree-walk widening (#4225) is still keyed by session name via GetMeta.
+func (p *Provider) processAliveByPane(ctx context.Context, name, paneID string, processNames []string) bool {
+ if paneID == "" {
+ return false
+ }
+ shellPID, fg, err := p.c.processInfo(ctx, paneID)
if err != nil || shellPID == 0 {
return false
}
@@ -412,6 +485,85 @@ func (p *Provider) ProcessAlive(name string, processNames []string) bool {
return processTreeAlive(shellPID, fg, processNames, strings.TrimSpace(sessionID))
}
+// startAgentAdopting issues the kind-launch agent start and, on herdr's
+// agent_name_taken rejection (a concurrent Start won the name), adopts the
+// live holder or reaps a stale one and retries once — breaking the recreate
+// storm (see resolveAgentNameTaken). Holder liveness is the pane busy probe:
+// a contested holder whose pane runs a foreground process is a live agent
+// (version-robust, unlike matching claude ≥2.1.x's comm strings). adopted is
+// true only when an already-running holder was adopted, so the caller can
+// skip re-priming a live agent.
+func (p *Provider) startAgentAdopting(ctx context.Context, name, kind, paneID string, args []string) (info agentInfo, adopted bool, err error) {
+ hn := herdrAgentName(name) // herdr ≥0.7.5 rejects raw gc session names (invalid_agent_name)
+ started, startErr := p.c.startAgentKind(ctx, hn, kind, paneID, args)
+ return resolveAgentNameTaken(started, startErr, agentStartOps{
+ getAgent: func() (agentInfo, bool, error) { return p.c.getAgent(ctx, herdrAgentName(name)) },
+ paneAlive: func(holderPane string) bool {
+ probe, perr := p.probePane(ctx, holderPane)
+ return perr == nil && probe.Exists && probe.Busy
+ },
+ closePane: func(holderPane string) error { return p.c.closePane(ctx, holderPane) },
+ retryStart: func() (agentInfo, error) { return p.c.startAgentKind(ctx, hn, kind, paneID, args) },
+ })
+}
+
+// paneBusyRetries bounds how many agent_pane_busy rejections the kind launch
+// retries after re-waiting for the shell prompt (races between the readiness
+// probe and herdr's own availability check).
+const paneBusyRetries = 3
+
+// paneShellReadyWait bounds the wait for a fresh pane's shell to reach its
+// interactive prompt (rc files can run for seconds and spawn foreground
+// children). Best-effort: on timeout the launch proceeds and surfaces
+// herdr's own verdict.
+const paneShellReadyWait = 15 * time.Second
+
+// waitPaneShellReady polls the pane until it idles at a bare interactive
+// shell prompt — what herdr's `agent start` requires of its target pane.
+func (p *Provider) waitPaneShellReady(ctx context.Context, paneID string) {
+ deadline := time.Now().Add(paneShellReadyWait)
+ for time.Now().Before(deadline) {
+ probe, err := p.probePane(ctx, paneID)
+ if err == nil && probe.Exists && !probe.Busy {
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// rawLaunchWait bounds how long Start's raw path waits for the typed
+// `exec /bin/sh -c …` to actually execute in the fresh pane. The typed launch
+// runs only after the pane's shell finishes initializing (rc files can take
+// seconds and spawn their own foreground children, so pane busyness alone
+// cannot confirm the launch). The bound only bites on a wedged shell, after
+// which Start proceeds best-effort (the reconciler tolerates a slow launch).
+const rawLaunchWait = 15 * time.Second
+
+// waitPaneLaunched polls the pane until the launched `/bin/sh -c ` shows
+// up in its foreground (exec preserves argv), the pane is gone (the command
+// already ran and exited), or the bound elapses. Best-effort by design.
+func (p *Provider) waitPaneLaunched(ctx context.Context, paneID, raw string) {
+ deadline := time.Now().Add(rawLaunchWait)
+ for time.Now().Before(deadline) {
+ shellPID, fg, err := p.c.processInfo(ctx, paneID)
+ switch {
+ case err != nil && (strings.Contains(err.Error(), "not_found") || strings.Contains(err.Error(), "not found")):
+ return // pane already gone: the command ran and exited
+ case err == nil && shellPID != 0 && (paneRunsCommand(fg, raw) || paneRootReplaced(shellPID, fg)):
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
// processTreeAlive is the descendant-walk fallback for ProcessAlive: it takes
// a host-wide process snapshot and checks whether any process reachable from
// the pane's shell PID or foreground PIDs matches one of processNames. When
@@ -471,7 +623,21 @@ func (p *Provider) ObserveLiveness(name string, _ []string) runtime.Liveness {
if strings.TrimSpace(name) == "" {
return runtime.Liveness{}
}
- info, present, err := p.c.getAgent(context.Background(), name)
+ ctx := context.Background()
+ info, present, err := p.c.getAgent(ctx, herdrAgentName(name))
+ if err == nil && !present {
+ // Name absent — fall back to the bound pane before declaring the
+ // session gone: raw shell sessions never register a name at all, and
+ // herdr ≥0.7.4 clears a registered name on occupant change. A binding
+ // that resolves as running means the session is up even though no
+ // agent_status is readable; report alive, matching
+ // agentAliveFromStatus's fail-safe direction. A confirmed-gone pane
+ // clears the stale binding; a transport failure clears nothing and
+ // falls through to not-running (as a failed name query already does).
+ if _, running, perr := resolveBinding(p.lookupOps(ctx, name)); perr == nil && running {
+ return runtime.Liveness{Running: true, Alive: true}
+ }
+ }
return livenessFromAgent(info, present, err)
}
@@ -511,24 +677,53 @@ func (p *Provider) Nudge(name string, content []runtime.ContentBlock) error {
if err != nil || pid == "" {
return runtime.ErrSessionNotFound
}
- return p.c.deliverNudge(ctx, pid, name, runtime.FlattenText(content))
+ return p.c.deliverNudge(ctx, pid, runtime.FlattenText(content))
}
// Peek reads the current rendered screen ("visible") — the liveness/fingerprint
-// snapshot. recent*/scrollback is empty until lines scroll off.
+// snapshot. It reads by pane (resolved through the binding when the registry
+// name is gone), since raw shell sessions have no registered agent to read.
func (p *Provider) Peek(name string, lines int) (string, error) {
- return p.c.read(context.Background(), name, "visible", lines)
+ ctx := context.Background()
+ pid, err := p.paneID(ctx, name)
+ if err != nil {
+ return "", err
+ }
+ if pid == "" {
+ return "", runtime.ErrSessionNotFound
+ }
+ return p.c.paneRead(ctx, pid, "visible", lines)
}
-// ListRunning returns the names of running agents whose names start with prefix.
+// ListRunning returns the names of running sessions whose names start with
+// prefix. The sidecar bindings are the primary source (they hold the exact
+// gc names — herdr's registry stores the mapped herdrAgentName forms, and
+// never sees raw shell sessions at all); each bound candidate is verified
+// running before it is listed. Registry agents that don't correspond to any
+// bound gc session (foreign/manual agents) are appended under their own
+// names.
func (p *Provider) ListRunning(prefix string) ([]string, error) {
- agents, err := p.c.listAgents(context.Background())
+ ctx := context.Background()
+ agents, err := p.c.listAgents(ctx)
if err != nil {
return nil, err
}
+ seen := make(map[string]bool) // gc names already listed
+ mapped := make(map[string]bool) // herdr-side names owned by bound gc sessions
var out []string
+ for _, name := range p.boundSessionNames() {
+ mapped[herdrAgentName(name)] = true
+ if !strings.HasPrefix(name, prefix) || seen[name] {
+ continue
+ }
+ if _, running, err := resolveBinding(p.lookupOps(ctx, name)); err == nil && running {
+ seen[name] = true
+ out = append(out, name)
+ }
+ }
for _, a := range agents {
- if strings.HasPrefix(a.Name, prefix) {
+ if !mapped[a.Name] && strings.HasPrefix(a.Name, prefix) && !seen[a.Name] {
+ seen[a.Name] = true
out = append(out, a.Name)
}
}
@@ -576,7 +771,7 @@ func (p *Provider) CopyTo(name, src, relDst string) error {
if _, err := os.Stat(src); err != nil {
return nil // best-effort: missing src
}
- a, ok, err := p.c.getAgent(context.Background(), name)
+ a, ok, err := p.c.getAgent(context.Background(), herdrAgentName(name))
if err != nil || !ok || a.Cwd == "" {
return nil
}
@@ -645,24 +840,15 @@ func (p *Provider) clearMeta(name string) error {
// ── helpers ──────────────────────────────────────────────────────────────────
-// paneID resolves a gascity session name to its herdr pane id (or "" if absent).
+// paneID resolves a gascity session name to its herdr pane id (or "" if
+// absent): registry name lookup first, then the sidecar pane binding Start
+// persisted — the only handle for raw shell sessions and for agents whose
+// registry name herdr cleared (see panebinding.go). The pane resolves
+// whenever it still exists, even for an exited agent, so Stop/keys/read keep
+// working on it.
func (p *Provider) paneID(ctx context.Context, name string) (string, error) {
- a, ok, err := p.c.getAgent(ctx, name)
- if err != nil {
- return "", err
- }
- if !ok {
- return "", nil
- }
- return a.PaneID, nil
-}
-
-// shellArgv wraps a shell command string as argv for `herdr agent start -- …`.
-func shellArgv(command string) []string {
- if strings.TrimSpace(command) == "" {
- return []string{"/bin/sh"}
- }
- return []string{"/bin/sh", "-c", command}
+ pane, _, err := resolveBinding(p.lookupOps(ctx, name))
+ return pane, err
}
// workspaceTabFor maps a gascity runtime session name to its herdr placement: a
diff --git a/internal/runtime/k8s/beads_script_test.go b/internal/runtime/k8s/beads_script_test.go
index 83114445bd..ebca32cb74 100644
--- a/internal/runtime/k8s/beads_script_test.go
+++ b/internal/runtime/k8s/beads_script_test.go
@@ -274,6 +274,7 @@ type beadsScriptOptions struct {
PodPhase string
ListOutput string
ReadyOutput string
+ Stdin string
}
type beadsScriptResult struct {
@@ -328,7 +329,7 @@ if [[ "$joined" == *" wait --for=condition=Ready pod/gc-beads-runner "* ]]; then
exit 0
fi
if [[ "$joined" == *" exec gc-beads-runner -- sh -c "* ]]; then
- if [[ "$*" == *"bd list --json --limit 0 --all"* ]]; then
+ if [[ "$*" == *" list --json --limit 0 --all"* ]]; then
printf '%%s' "$list_output"
exit 0
fi
@@ -355,6 +356,9 @@ exit 1
for key, value := range opts.Env {
cmd.Env = append(cmd.Env, key+"="+value)
}
+ if opts.Stdin != "" {
+ cmd.Stdin = strings.NewReader(opts.Stdin)
+ }
out, err := cmd.CombinedOutput()
callLogBytes, readCallErr := os.ReadFile(callLogPath)
@@ -402,3 +406,121 @@ func beadsScriptPath(t *testing.T) string {
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..", "contrib", "beads-scripts", "gc-beads-k8s"))
}
+
+// beadsScriptUpdateEnv is the projected scope env an update runs under.
+var beadsScriptUpdateEnv = map[string]string{
+ "GC_CITY_PATH": "/city", "GC_STORE_ROOT": "/city/rigs/testrig", "GC_BEADS_PREFIX": "tr",
+}
+
+// TestBeadsScriptUpdateForwardsEveryDocumentedField pins the generated `bd
+// update` argv for every field the update request may carry (see
+// docs/reference/exec-beads-provider.md). A dropped field makes the write
+// silently succeed while the change is lost: dropping `type`, for instance,
+// leaves a graph.v2 step at type=gate forever — ready-excluded, so never
+// dispatched — even though activation reported success.
+func TestBeadsScriptUpdateForwardsEveryDocumentedField(t *testing.T) {
+ result := runBeadsScript(t, beadsScriptOptions{
+ Op: "update",
+ Args: []string{"tr-abc"},
+ Stdin: `{"title":"renamed","status":"in_progress","type":"task","priority":1,` +
+ `"description":"note","assignee":"worker-1","parent_id":"tr-parent",` +
+ `"labels":["added"],"remove_labels":["dropped"]}`,
+ Env: beadsScriptUpdateEnv,
+ })
+ if result.err != nil {
+ t.Fatalf("gc-beads-k8s update error = %v\noutput:\n%s", result.err, result.output)
+ }
+ for _, want := range []string{
+ "--title renamed",
+ "--status in_progress",
+ "--type task",
+ "--priority 1",
+ "--description note",
+ "--assignee worker-1",
+ "--parent tr-parent",
+ "--add-label added",
+ "--remove-label dropped",
+ } {
+ assertCallContains(t, result.callLog, want)
+ }
+}
+
+// TestBeadsScriptUpdateOmitsAbsentFields pins that fields absent from the wire
+// are not spuriously passed to bd as empty flags, so updating one field cannot
+// clobber the others.
+func TestBeadsScriptUpdateOmitsAbsentFields(t *testing.T) {
+ result := runBeadsScript(t, beadsScriptOptions{
+ Op: "update",
+ Args: []string{"tr-abc"},
+ Stdin: `{"description":"just a note"}`,
+ Env: beadsScriptUpdateEnv,
+ })
+ if result.err != nil {
+ t.Fatalf("gc-beads-k8s update error = %v\noutput:\n%s", result.err, result.output)
+ }
+ assertCallContains(t, result.callLog, "--description just a note")
+ for _, absent := range []string{
+ "--title", "--status", "--type", "--priority",
+ "--assignee", "--parent", "--add-label", "--remove-label",
+ } {
+ assertCallNotContains(t, result.callLog, absent)
+ }
+}
+
+// TestBeadsScriptListProjectsParentAndPriority pins the read half of the write
+// path above. The update op writes the parent natively via `bd --parent` and
+// forwards `--priority`, so a projection that reconstructs parent_id from
+// `parent:` labels alone — or omits priority entirely — turns a successful
+// re-parent into a silently lost write on the next read.
+func TestBeadsScriptListProjectsParentAndPriority(t *testing.T) {
+ tests := []struct {
+ name string
+ listOutput string
+ wantParent string
+ }{
+ {
+ // Native .parent wins over a stale parent: label, which is what a
+ // re-parent leaves behind.
+ name: "native parent wins over legacy label",
+ listOutput: `[{"id":"tr-a","title":"t","labels":["parent:tr-old"],"parent":"tr-new","priority":1}]`,
+ wantParent: "tr-new",
+ },
+ {
+ // Beads written before --parent carry only the label.
+ name: "legacy label when no native parent",
+ listOutput: `[{"id":"tr-a","title":"t","labels":["parent:tr-old"],"priority":1}]`,
+ wantParent: "tr-old",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := runBeadsScript(t, beadsScriptOptions{
+ Op: "list",
+ Env: map[string]string{
+ "GC_CITY_PATH": "/city", "GC_STORE_ROOT": "/city/rigs/testrig", "GC_BEADS_PREFIX": "tr",
+ },
+ ListOutput: tc.listOutput,
+ })
+ if result.err != nil {
+ t.Fatalf("gc-beads-k8s list error = %v\noutput:\n%s", result.err, result.output)
+ }
+ var got []struct {
+ ID string `json:"id"`
+ ParentID string `json:"parent_id"`
+ Priority *int `json:"priority"`
+ }
+ if err := json.Unmarshal([]byte(result.output), &got); err != nil {
+ t.Fatalf("parse list output: %v\noutput:\n%s", err, result.output)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d beads, want 1\noutput:\n%s", len(got), result.output)
+ }
+ if got[0].ParentID != tc.wantParent {
+ t.Errorf("parent_id = %q, want %q", got[0].ParentID, tc.wantParent)
+ }
+ if got[0].Priority == nil || *got[0].Priority != 1 {
+ t.Errorf("priority = %v, want 1", got[0].Priority)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/k8s/pod.go b/internal/runtime/k8s/pod.go
index f56b045570..300df10e90 100644
--- a/internal/runtime/k8s/pod.go
+++ b/internal/runtime/k8s/pod.go
@@ -15,11 +15,18 @@ import (
"github.com/gastownhall/gascity/internal/citylayout"
"github.com/gastownhall/gascity/internal/pathutil"
"github.com/gastownhall/gascity/internal/runtime"
+ "github.com/gastownhall/gascity/internal/shellquote"
)
const (
podManagedDoltHost = "dolt.gc.svc.cluster.local"
podManagedDoltPort = "3307"
+
+ // podWorkspaceRoot is the pod-side projection of the city root. It is the
+ // only directory guaranteed to exist when the container starts — it is the
+ // "ws" EmptyDir mount point for staged pods and the image WORKDIR for
+ // prebaked ones — so it is what the pod spec's WorkingDir may safely name.
+ podWorkspaceRoot = "/workspace"
)
func controllerCityPath(cfgEnv map[string]string) string {
@@ -45,12 +52,15 @@ func remapControllerPathToPod(val, ctrlCity string) string {
return val
}
+// projectedPodWorkDir maps the controller-side WorkDir onto its pod-side path.
+// For a pool or workflow worker this is a per-bead directory
+// (/-) that does not exist until the entrypoint creates it.
func projectedPodWorkDir(cfg runtime.Config) string {
- podWorkDir := "/workspace"
+ podWorkDir := podWorkspaceRoot
ctrlCity := controllerCityPath(cfg.Env)
if ctrlCity != "" && cfg.WorkDir != "" && cfg.WorkDir != ctrlCity {
if rel, ok := strings.CutPrefix(cfg.WorkDir, ctrlCity+"/"); ok {
- podWorkDir = "/workspace/" + rel
+ podWorkDir = podWorkspaceRoot + "/" + rel
}
}
return podWorkDir
@@ -250,19 +260,33 @@ func buildPod(name string, cfg runtime.Config, p *Provider) (*corev1.Pod, error)
wsWait = `while [ ! -f /workspace/.gc-workspace-ready ]; do sleep 0.5; done; `
}
+ // The pod spec's WorkingDir names the workspace root, because the kubelet
+ // chdirs into it before this command runs and a per-bead workDir does not
+ // exist yet. Create and enter the real working directory here instead.
+ //
+ // Placement matters twice over. It must come *after* wsWait, because until
+ // staging signals ready the workspace content is still being written and a
+ // shell sitting in a subdirectory of it is standing on shifting ground. And
+ // it must come *before* preStartCmds, because pre_start previously ran in
+ // podWorkDir (the container's WorkingDir) and must keep doing so.
+ enterWorkDir := fmt.Sprintf("mkdir -p %s && cd %s && ",
+ shellquote.Quote(podWorkDir), shellquote.Quote(podWorkDir))
+
var tmuxCmd string
if linuxUsername != "" {
- // Run tmux session as the dynamic user via su.
+ // Run tmux session as the dynamic user via su. userSetup already created
+ // and chowned podWorkDir as root; enterWorkDir is idempotent and is what
+ // puts pre_start in the right directory.
tmuxCmd = fmt.Sprintf(
- "%s%s%s%sCMD=$(echo '%s' | base64 -d) && "+
+ "%s%s%s%s%sCMD=$(echo '%s' | base64 -d) && "+
`su - %s -c "cd %s && tmux new-session -d -s %s \"$CMD\" && sleep infinity"`,
- userSetup, credCopy, wsWait, preStartCmds, cmdB64,
+ userSetup, credCopy, wsWait, enterWorkDir, preStartCmds, cmdB64,
linuxUsername, podWorkDir, tmuxSession,
)
} else {
tmuxCmd = fmt.Sprintf(
- "%s%s%sCMD=$(echo '%s' | base64 -d) && tmux new-session -d -s %s \"$CMD\" && sleep infinity",
- credCopy, wsWait, preStartCmds, cmdB64, tmuxSession,
+ "%s%s%s%sCMD=$(echo '%s' | base64 -d) && tmux new-session -d -s %s \"$CMD\" && sleep infinity",
+ credCopy, wsWait, enterWorkDir, preStartCmds, cmdB64, tmuxSession,
)
}
@@ -335,7 +359,14 @@ func buildPod(name string, cfg runtime.Config, p *Provider) (*corev1.Pod, error)
Name: "agent",
Image: p.image,
ImagePullPolicy: corev1.PullAlways,
- WorkingDir: podWorkDir,
+ // Not podWorkDir: the runtime resolves this before the entrypoint
+ // runs, so naming a per-bead directory that nothing has created
+ // yet is unsafe. containerd creates the whole chain itself as
+ // root:root 0755, leaving the non-root agent unable to write into
+ // its own working directory; other runtimes may refuse to start
+ // the container. The entrypoint creates and enters podWorkDir
+ // itself, as the agent user, so it comes out owned correctly.
+ WorkingDir: podWorkspaceRoot,
Command: []string{"/bin/sh", "-c"},
Args: []string{tmuxCmd},
Env: env,
diff --git a/internal/runtime/k8s/pod_test.go b/internal/runtime/k8s/pod_test.go
index 434b10f62c..65a7fbb3ae 100644
--- a/internal/runtime/k8s/pod_test.go
+++ b/internal/runtime/k8s/pod_test.go
@@ -1,11 +1,14 @@
package k8s
import (
+ "encoding/base64"
+ "strings"
"testing"
corev1 "k8s.io/api/core/v1"
"github.com/gastownhall/gascity/internal/runtime"
+ "github.com/gastownhall/gascity/internal/shellquote"
)
func TestBuildPod_NodeSelector(t *testing.T) {
@@ -141,3 +144,161 @@ func TestBuildPod_ClonesSchedulingFields(t *testing.T) {
t.Fatalf("provider affinity value mutated to %q", values[0])
}
}
+
+// perBeadWorkDirConfig is a pool/workflow worker's runtime config: WorkDir is a
+// per-bead directory under the rig (/-) that nothing has
+// created yet.
+func perBeadWorkDirConfig() runtime.Config {
+ return runtime.Config{
+ Command: "/bin/bash",
+ WorkDir: "/city/rigs/testrig/tr-abc-slug",
+ Env: map[string]string{"GC_CITY": "/city"},
+ }
+}
+
+const perBeadPodWorkDir = "/workspace/rigs/testrig/tr-abc-slug"
+
+// TestBuildPod_WorkingDirIsAlwaysAnExistingPath pins that the pod spec never
+// names a directory that may not exist yet. The kubelet chdirs into the
+// container's WorkingDir before the entrypoint runs, so a per-bead WorkingDir
+// is created by the runtime as root:root (containerd) or rejected outright —
+// either way no command, including pre_start, gets to create it correctly.
+// The workspace root always exists (EmptyDir mount when staged, WORKDIR in the
+// prebaked image), so the spec points there and the entrypoint enters the
+// per-bead directory itself.
+func TestBuildPod_WorkingDirIsAlwaysAnExistingPath(t *testing.T) {
+ for _, prebaked := range []bool{false, true} {
+ name := "staged"
+ if prebaked {
+ name = "prebaked"
+ }
+ t.Run(name, func(t *testing.T) {
+ p := newProviderWithOps(newFakeK8sOps())
+ p.prebaked = prebaked
+ pod, err := buildPod("test-session", perBeadWorkDirConfig(), p)
+ if err != nil {
+ t.Fatalf("buildPod: %v", err)
+ }
+ if got := pod.Spec.Containers[0].WorkingDir; got != podWorkspaceRoot {
+ t.Errorf("WorkingDir = %q, want %q (a path guaranteed to exist)", got, podWorkspaceRoot)
+ }
+ })
+ }
+}
+
+// TestBuildPod_EntrypointCreatesAndEntersWorkDir pins that the entrypoint
+// creates the per-bead WorkingDir and cds into it, so the agent still starts in
+// its own directory. This must hold for prebaked images too: prebaked pods
+// mount no shared volume, so an init container physically cannot create a
+// directory the main container would see.
+func TestBuildPod_EntrypointCreatesAndEntersWorkDir(t *testing.T) {
+ for _, prebaked := range []bool{false, true} {
+ name := "staged"
+ if prebaked {
+ name = "prebaked"
+ }
+ t.Run(name, func(t *testing.T) {
+ p := newProviderWithOps(newFakeK8sOps())
+ p.prebaked = prebaked
+ pod, err := buildPod("test-session", perBeadWorkDirConfig(), p)
+ if err != nil {
+ t.Fatalf("buildPod: %v", err)
+ }
+ args := strings.Join(pod.Spec.Containers[0].Args, " ")
+ quoted := shellquote.Quote(perBeadPodWorkDir)
+ if !strings.Contains(args, "mkdir -p "+quoted) {
+ t.Errorf("entrypoint should mkdir the per-bead WorkingDir; got: %s", args)
+ }
+ if !strings.Contains(args, "cd "+quoted) {
+ t.Errorf("entrypoint should cd into the per-bead WorkingDir; got: %s", args)
+ }
+ })
+ }
+}
+
+// TestBuildPod_EntrypointCreatesWorkDirAsDynamicUser pins the same contract on
+// the LINUX_USERNAME path, where root creates and chowns the directory before
+// dropping privileges and the tmux session cds into it.
+func TestBuildPod_EntrypointCreatesWorkDirAsDynamicUser(t *testing.T) {
+ p := newProviderWithOps(newFakeK8sOps())
+ cfg := perBeadWorkDirConfig()
+ cfg.Env["LINUX_USERNAME"] = "gcagent"
+
+ pod, err := buildPod("test-session", cfg, p)
+ if err != nil {
+ t.Fatalf("buildPod: %v", err)
+ }
+ args := strings.Join(pod.Spec.Containers[0].Args, " ")
+ if !strings.Contains(args, "mkdir -p \""+perBeadPodWorkDir+"\"") {
+ t.Errorf("entrypoint should mkdir the per-bead WorkingDir as root; got: %s", args)
+ }
+ if !strings.Contains(args, "cd "+perBeadPodWorkDir) {
+ t.Errorf("tmux session should start in the per-bead WorkingDir; got: %s", args)
+ }
+}
+
+// TestBuildPod_EntersWorkDirAfterStagingAndBeforePreStart pins the ordering of
+// the entrypoint, which two silent regressions depend on. Entering the work dir
+// must happen after the staging wait, or the shell sits in a subdirectory of a
+// workspace that is still being written. And it must happen before pre_start,
+// because pre_start used to run in the container's WorkingDir — which was the
+// per-bead dir — and commands there may use relative paths.
+func TestBuildPod_EntersWorkDirAfterStagingAndBeforePreStart(t *testing.T) {
+ for _, username := range []string{"", "gcagent"} {
+ name := "no-dynamic-user"
+ if username != "" {
+ name = "dynamic-user"
+ }
+ t.Run(name, func(t *testing.T) {
+ p := newProviderWithOps(newFakeK8sOps())
+ cfg := perBeadWorkDirConfig()
+ cfg.PreStart = []string{"echo pre-start-marker"}
+ if username != "" {
+ cfg.Env["LINUX_USERNAME"] = username
+ }
+ pod, err := buildPod("test-session", cfg, p)
+ if err != nil {
+ t.Fatalf("buildPod: %v", err)
+ }
+ args := strings.Join(pod.Spec.Containers[0].Args, " ")
+
+ stagingWait := strings.Index(args, ".gc-workspace-ready")
+ enter := strings.Index(args, "cd "+shellquote.Quote(perBeadPodWorkDir))
+ // pre_start commands are base64-encoded into the entrypoint.
+ preStart := strings.Index(args, base64.StdEncoding.EncodeToString([]byte("echo pre-start-marker")))
+
+ if stagingWait < 0 || enter < 0 || preStart < 0 {
+ t.Fatalf("entrypoint missing a stage (wait=%d enter=%d preStart=%d): %s",
+ stagingWait, enter, preStart, args)
+ }
+ if enter < stagingWait {
+ t.Errorf("entering the work dir must come after the staging wait; got: %s", args)
+ }
+ if preStart < enter {
+ t.Errorf("pre_start must run after entering the work dir; got: %s", args)
+ }
+ })
+ }
+}
+
+// TestBuildPod_InitContainerOnlyWaitsForStaging pins that the staging init
+// container is back to a single responsibility — waiting for the controller to
+// finish staging. Creating the WorkingDir there only ever worked for staged,
+// non-prebaked pods; the entrypoint now owns it for every topology.
+func TestBuildPod_InitContainerOnlyWaitsForStaging(t *testing.T) {
+ p := newProviderWithOps(newFakeK8sOps())
+ pod, err := buildPod("test-session", perBeadWorkDirConfig(), p)
+ if err != nil {
+ t.Fatalf("buildPod: %v", err)
+ }
+ if len(pod.Spec.InitContainers) != 1 {
+ t.Fatalf("len(InitContainers) = %d, want 1", len(pod.Spec.InitContainers))
+ }
+ cmd := strings.Join(pod.Spec.InitContainers[0].Command, " ")
+ if strings.Contains(cmd, "mkdir") {
+ t.Errorf("init container should not create the WorkingDir; got: %s", cmd)
+ }
+ if !strings.Contains(cmd, ".gc-ready") {
+ t.Errorf("init container should wait for staging; got: %s", cmd)
+ }
+}
diff --git a/internal/runtime/k8s/provider.go b/internal/runtime/k8s/provider.go
index 8ffa87a946..c6e51ddcdd 100644
--- a/internal/runtime/k8s/provider.go
+++ b/internal/runtime/k8s/provider.go
@@ -533,13 +533,16 @@ func (p *Provider) ProcessAlive(name string, processNames []string) bool {
// Uses -l (literal mode) so tmux key names in the message text are not
// interpreted as keystrokes. Content blocks are flattened to text.
func (p *Provider) Nudge(name string, content []runtime.ContentBlock) error {
- _ = p.carrier().Nudge(context.Background(), name, content) // best-effort
- return nil
+ return p.carrier().Nudge(context.Background(), name, content)
}
-// SendKeys sends bare keystrokes to the tmux session.
+// SendKeys sends bare keystrokes to the tmux session. Best-effort on a
+// missing session (contract: no-op), but a genuine transport failure to a
+// live pod is propagated (#4389).
func (p *Provider) SendKeys(name string, keys ...string) error {
- _ = p.carrier().SendKeys(context.Background(), name, keys...) // best-effort
+ if err := p.carrier().SendKeys(context.Background(), name, keys...); err != nil && !errors.Is(err, runtime.ErrSessionNotFound) {
+ return err
+ }
return nil
}
@@ -718,6 +721,11 @@ func (p *Provider) Exec(ctx context.Context, name string, argv []string) ([]byte
return []byte(out), 0, nil
}
+// findRunningPod resolves the running pod for name. A missing pod (scaled
+// down, evicted, never provisioned) is reported as [runtime.ErrSessionNotFound]
+// so callers can distinguish "session is gone" from a genuine transport
+// failure reaching a pod that does exist — the same distinction Relaunch
+// already draws at its own call site.
func (p *Provider) findRunningPod(ctx context.Context, name string) (string, error) {
label := SanitizeLabel(name)
pods, err := p.ops.listPods(ctx, "gc-session="+label, "status.phase=Running")
@@ -725,7 +733,7 @@ func (p *Provider) findRunningPod(ctx context.Context, name string) (string, err
return "", err
}
if len(pods) == 0 {
- return "", fmt.Errorf("no running pod for session %q", name)
+ return "", fmt.Errorf("%w: no running pod for session %q", runtime.ErrSessionNotFound, name)
}
return pods[0].Name, nil
}
@@ -831,7 +839,7 @@ func initCityInPod(ctx context.Context, ops k8sOps, podName, ctrlCity string) er
// start a local Dolt server. Pod sessions consume the projected GC_DOLT_*
// connection target through env; they do not rewrite canonical .beads files.
_, err := ops.execInPod(ctx, podName, "agent",
- []string{"env", "GC_DOLT=skip", "gc", "init", "--from", "/tmp/city-src", "/workspace"}, nil)
+ []string{"env", "GC_DOLT=skip", "gc", "init", "--from", "/tmp/city-src", "/workspace", "--no-start", "--skip-provider-readiness"}, nil)
if err != nil {
return err
}
diff --git a/internal/runtime/k8s/provider_test.go b/internal/runtime/k8s/provider_test.go
index 02510c1b49..896b3a4ea8 100644
--- a/internal/runtime/k8s/provider_test.go
+++ b/internal/runtime/k8s/provider_test.go
@@ -13,6 +13,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/gastownhall/gascity/internal/runtime"
+ "github.com/gastownhall/gascity/internal/shellquote"
)
func TestProviderImplementsInterface(_ *testing.T) {
@@ -349,6 +350,89 @@ func TestSendKeys(t *testing.T) {
}
}
+// TestNudgePropagatesTransportError verifies that a transport failure (no
+// running pod for the session) surfaces as a non-nil error instead of being
+// swallowed — Nudge is not best-effort at the delivery layer, callers up
+// through worker.RuntimeHandle.Nudge and `gc session nudge` rely on this
+// error to report failed delivery (#4389). It also verifies the missing-pod
+// case is specifically [runtime.ErrSessionNotFound] — distinct from a live
+// pod's exec-stream failure — so callers like internal/session/chat.go and
+// internal/api/session_resolution.go can no-op on a gone session instead of
+// hard-failing (sjarmak's #4405 review).
+func TestNudgePropagatesTransportError(t *testing.T) {
+ fake := newFakeK8sOps()
+ p := newProviderWithOps(fake)
+
+ // No pod registered for this session name, so findRunningPod fails.
+ err := p.Nudge("gc-missing-agent", runtime.TextContent("hello world"))
+ if err == nil {
+ t.Fatal("Nudge: expected error for missing pod, got nil")
+ }
+ if !errors.Is(err, runtime.ErrSessionNotFound) {
+ t.Errorf("Nudge missing-pod error = %v, want errors.Is(..., runtime.ErrSessionNotFound)", err)
+ }
+}
+
+// TestSendKeysMissingSessionIsNoOp verifies SendKeys honors the documented
+// best-effort contract (runtime.go SendKeys_MissingSession): a missing pod
+// (ErrSessionNotFound at the carrier) is a no-op returning nil, not an error.
+// This is the deliberate asymmetry with Nudge (#4389/#4405): SendKeys is
+// best-effort on a gone session, while a genuine transport failure to a live
+// pod still propagates (see TestSendKeysExecStreamFailureIsNotErrSessionNotFound).
+func TestSendKeysMissingSessionIsNoOp(t *testing.T) {
+ fake := newFakeK8sOps()
+ p := newProviderWithOps(fake)
+
+ err := p.SendKeys("gc-missing-agent", "Down", "Enter")
+ if err != nil {
+ t.Fatalf("SendKeys: expected nil for missing pod (best-effort contract), got %v", err)
+ }
+}
+
+// TestNudgeExecStreamFailureIsNotErrSessionNotFound verifies the other half
+// of sjarmak's #4405 review: a running pod whose exec stream fails (a real
+// transport failure — #4389's actual bug) must NOT be mistaken for a gone
+// session. Only the pod-not-found case is ErrSessionNotFound; this failure
+// mode must propagate as a plain error so callers correctly treat it as a
+// hard failure rather than silently no-opping.
+func TestNudgeExecStreamFailureIsNotErrSessionNotFound(t *testing.T) {
+ fake := newFakeK8sOps()
+ p := newProviderWithOps(fake)
+
+ addRunningPod(fake, "gc-test-agent", "gc-test-agent")
+ fake.setExecResult("gc-test-agent",
+ []string{"tmux", "send-keys", "-t", "main", "-l", "hello world"},
+ "", errors.New("stream error: broken pipe"))
+
+ err := p.Nudge("gc-test-agent", runtime.TextContent("hello world"))
+ if err == nil {
+ t.Fatal("Nudge: expected error for exec-stream failure, got nil")
+ }
+ if errors.Is(err, runtime.ErrSessionNotFound) {
+ t.Errorf("Nudge exec-stream-failure error = %v, must NOT be ErrSessionNotFound (pod exists, this is a real transport failure)", err)
+ }
+}
+
+// TestSendKeysExecStreamFailureIsNotErrSessionNotFound mirrors
+// TestNudgeExecStreamFailureIsNotErrSessionNotFound for SendKeys.
+func TestSendKeysExecStreamFailureIsNotErrSessionNotFound(t *testing.T) {
+ fake := newFakeK8sOps()
+ p := newProviderWithOps(fake)
+
+ addRunningPod(fake, "gc-test-agent", "gc-test-agent")
+ fake.setExecResult("gc-test-agent",
+ []string{"tmux", "send-keys", "-t", "main", "Down", "Enter"},
+ "", errors.New("stream error: broken pipe"))
+
+ err := p.SendKeys("gc-test-agent", "Down", "Enter")
+ if err == nil {
+ t.Fatal("SendKeys: expected error for exec-stream failure, got nil")
+ }
+ if errors.Is(err, runtime.ErrSessionNotFound) {
+ t.Errorf("SendKeys exec-stream-failure error = %v, must NOT be ErrSessionNotFound (pod exists, this is a real transport failure)", err)
+ }
+}
+
func TestInterrupt(t *testing.T) {
fake := newFakeK8sOps()
p := newProviderWithOps(fake)
@@ -774,10 +858,16 @@ func TestPodManifestCompatibility(t *testing.T) {
}
}
- // Verify working directory is pod-mapped.
- if pod.Spec.Containers[0].WorkingDir != "/workspace/demo-rig" {
- t.Errorf("workingDir = %q, want /workspace/demo-rig",
- pod.Spec.Containers[0].WorkingDir)
+ // The manifest's workingDir is the workspace root, which always exists —
+ // the kubelet chdirs there before the entrypoint runs. The pod-mapped agent
+ // directory is entered by the entrypoint instead. gc-session-k8s builds its
+ // manifest the same way, so the two providers stay interchangeable.
+ if pod.Spec.Containers[0].WorkingDir != podWorkspaceRoot {
+ t.Errorf("workingDir = %q, want %q",
+ pod.Spec.Containers[0].WorkingDir, podWorkspaceRoot)
+ }
+ if args := strings.Join(pod.Spec.Containers[0].Args, " "); !strings.Contains(args, "cd "+shellquote.Quote("/workspace/demo-rig")) {
+ t.Errorf("entrypoint should enter the pod-mapped agent dir; got: %s", args)
}
}
@@ -2198,4 +2288,21 @@ func TestInitCityInPodSkipsDolt(t *testing.T) {
if !hasSkip {
t.Errorf("gc init should run with GC_DOLT=skip; got cmd=%v", gcInitCmd)
}
+
+ // Pod-local init only scaffolds a session filesystem; it must not register
+ // or start a city, and must not run provider login/readiness probes (a
+ // gateway-backed provider cannot satisfy a first-party-login probe, and the
+ // controller owns readiness). Assert both flags are present.
+ for _, flag := range []string{"--no-start", "--skip-provider-readiness"} {
+ found := false
+ for _, arg := range gcInitCmd {
+ if arg == flag {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("gc init should run with %s; got cmd=%v", flag, gcInitCmd)
+ }
+ }
}
diff --git a/internal/runtime/k8s/session_script_test.go b/internal/runtime/k8s/session_script_test.go
index b33364d9c2..ca58a2e728 100644
--- a/internal/runtime/k8s/session_script_test.go
+++ b/internal/runtime/k8s/session_script_test.go
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "strings"
"testing"
)
@@ -118,8 +119,17 @@ func TestSessionScriptStartRigManifestUsesPodPaths(t *testing.T) {
if got := result.manifestEnv["GC_DIR"]; got != "/workspace/frontend" {
t.Fatalf("manifest GC_DIR = %q, want /workspace/frontend", got)
}
- if got := result.containerWorkingDir; got != "/workspace/frontend" {
- t.Fatalf("container workingDir = %q, want /workspace/frontend", got)
+ // The manifest's workingDir is the workspace root, which always exists: the
+ // kubelet chdirs there before the entrypoint runs, so naming a directory
+ // that nothing has created yet (a per-bead pool/workflow workDir) would leave
+ // the agent in a root-owned directory it cannot write into. The entrypoint
+ // creates and enters the pod-mapped agent dir itself.
+ if got := result.containerWorkingDir; got != podWorkspaceRoot {
+ t.Fatalf("container workingDir = %q, want %q", got, podWorkspaceRoot)
+ }
+ if got := result.containerArgs; !strings.Contains(got, "mkdir -p '/workspace/frontend'") ||
+ !strings.Contains(got, "cd '/workspace/frontend'") {
+ t.Fatalf("entrypoint should create and enter the pod-mapped agent dir; got: %s", got)
}
if got := result.manifestMounts["ws"]; got != "/workspace" {
t.Fatalf("ws mount = %q, want /workspace", got)
@@ -144,6 +154,7 @@ type sessionScriptStartResult struct {
manifestEnv map[string]string
manifestMounts map[string]string
containerWorkingDir string
+ containerArgs string
callLog string
output string
err error
@@ -227,12 +238,14 @@ exit 1
manifestEnv := map[string]string{}
manifestMounts := map[string]string{}
containerWorkingDir := ""
+ containerArgs := ""
manifestBytes, readManifestErr := os.ReadFile(manifestPath)
if readManifestErr == nil && len(manifestBytes) > 0 {
var manifest struct {
Spec struct {
Containers []struct {
- WorkingDir string `json:"workingDir"`
+ WorkingDir string `json:"workingDir"`
+ Args []string `json:"args"`
Env []struct {
Name string `json:"name"`
Value string `json:"value"`
@@ -249,6 +262,7 @@ exit 1
}
if len(manifest.Spec.Containers) > 0 {
containerWorkingDir = manifest.Spec.Containers[0].WorkingDir
+ containerArgs = strings.Join(manifest.Spec.Containers[0].Args, " ")
for _, item := range manifest.Spec.Containers[0].Env {
manifestEnv[item.Name] = item.Value
}
@@ -269,6 +283,7 @@ exit 1
manifestEnv: manifestEnv,
manifestMounts: manifestMounts,
containerWorkingDir: containerWorkingDir,
+ containerArgs: containerArgs,
callLog: string(callLogBytes),
output: string(out),
err: err,
diff --git a/internal/runtime/proctable/kill_unix.go b/internal/runtime/proctable/kill_unix.go
index e3f8fd01a3..455adc2532 100644
--- a/internal/runtime/proctable/kill_unix.go
+++ b/internal/runtime/proctable/kill_unix.go
@@ -24,9 +24,10 @@ func KillByPID(pid int) error {
// post-SIGKILL reap wait the PID can be reaped and recycled to an unrelated
// process; without this, a recycled PID reads as "still alive" and we would
// wrongly report a target that is actually gone as not-confirmed-dead,
- // spuriously refusing a legitimate Start. StartTime is empty on hosts
- // without /proc (darwin) or when the record is unreadable, in which case
- // runLive falls back to plain liveness — current behavior preserved.
+ // spuriously refusing a legitimate Start. StartTime reads /proc where it
+ // exists and falls back to ps elsewhere, so it is empty only when neither
+ // mechanism can answer, in which case runLive falls back to plain liveness
+ // — current behavior preserved.
startTime, _ := pidutil.StartTime(pid)
return killByPID(
pid,
diff --git a/internal/runtime/setupcommand.go b/internal/runtime/setupcommand.go
new file mode 100644
index 0000000000..6fbfdaf0e2
--- /dev/null
+++ b/internal/runtime/setupcommand.go
@@ -0,0 +1,135 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+const (
+ // setupCommandOutputLimit bounds how much stdout/stderr tail is retained
+ // per stream and folded into a setup-command failure message.
+ setupCommandOutputLimit = 4096
+ // setupCommandWaitDelay is how long after the command exits (or the
+ // timeout fires) Go forcibly closes the capture pipes, so background
+ // descendants that inherited stdio cannot block the wait indefinitely.
+ setupCommandWaitDelay = 2 * time.Second
+)
+
+// RunSetupCommand executes one session lifecycle shell command (pre_start,
+// session_setup, session_setup_script, session_live) host-side — "in gc's
+// process via sh -c", per the Config field contracts — with a per-command
+// timeout. The command's working directory is env["GC_DIR"] when set; env is
+// appended to the inherited process environment (last wins). On failure, a
+// bounded tail of the command's stdout/stderr is folded into the returned
+// error so operators can see why a setup command failed without hunting for
+// logs.
+//
+// Extracted from the tmux adapter as the shared core that host-side providers
+// (tmux, herdr) will delegate to, so lifecycle commands run with one set of
+// semantics: same GC_DIR cwd contract, same daemonizing-child tolerance, same
+// failure detail. As of this commit it has no callers — tmux
+// (internal/runtime/tmux/adapter.go) and herdr (internal/runtime/herdr/provider.go)
+// still run their own copies.
+//
+// PARITY REQUIRED BEFORE WIRING: both current callers have since grown an
+// execgrace layer this snapshot predates. Before either delegates here, this
+// runner must regain: execgrace.NewMonitor idle/ceiling budgets under
+// [session] setup_max_timeout (this version has a single fixed deadline),
+// execgrace.Apply cooperative process-group interrupt so shell rollback traps
+// run before SIGKILL (see adapter.go's note on stranded staged state), and
+// context.Cause in the failure wrap so the reported error names which budget
+// fired. Provider-specific behavior is also not covered here: tmux's
+// GC_TMUX_SOCKET injection and herdr's GC_DIR-exists-else-cityRoot fallback.
+func RunSetupCommand(ctx context.Context, command string, env map[string]string, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ c := exec.CommandContext(ctx, "sh", "-c", command)
+ if workDir := strings.TrimSpace(env["GC_DIR"]); workDir != "" {
+ c.Dir = workDir
+ }
+ c.Env = os.Environ()
+ for k, v := range env {
+ c.Env = append(c.Env, k+"="+v)
+ }
+ stdout := newCommandOutputTail(setupCommandOutputLimit)
+ stderr := newCommandOutputTail(setupCommandOutputLimit)
+ c.Stdout = stdout
+ c.Stderr = stderr
+ // WaitDelay ensures Go forcibly closes the capture pipes after the
+ // command exits or the timeout fires, even if background descendants
+ // spawned by the command still hold them open.
+ c.WaitDelay = setupCommandWaitDelay
+ if err := c.Run(); err != nil {
+ // ErrWaitDelay means the command itself exited successfully and
+ // only the force-closed pipes ended the wait: a setup command that
+ // daemonizes a child holding inherited stdio and exits 0 succeeded.
+ if errors.Is(err, exec.ErrWaitDelay) {
+ return nil
+ }
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ err = fmt.Errorf("%w: %w", ctxErr, err)
+ }
+ return setupCommandFailure(err, stdout, stderr)
+ }
+ return nil
+}
+
+// commandOutputTail is a bounded io.Writer that keeps only the last limit
+// bytes written, for folding command output into failure messages.
+type commandOutputTail struct {
+ limit int
+ written int
+ buf []byte
+}
+
+func newCommandOutputTail(limit int) *commandOutputTail {
+ return &commandOutputTail{limit: limit}
+}
+
+func (b *commandOutputTail) Write(p []byte) (int, error) {
+ b.written += len(p)
+ if b.limit <= 0 {
+ return len(p), nil
+ }
+ if len(p) >= b.limit {
+ b.buf = append(b.buf[:0], p[len(p)-b.limit:]...)
+ return len(p), nil
+ }
+ b.buf = append(b.buf, p...)
+ if len(b.buf) > b.limit {
+ copy(b.buf, b.buf[len(b.buf)-b.limit:])
+ b.buf = b.buf[:b.limit]
+ }
+ return len(p), nil
+}
+
+func (b *commandOutputTail) Detail(label string) string {
+ text := strings.TrimSpace(string(b.buf))
+ if text == "" {
+ return ""
+ }
+ if b.written > len(b.buf) {
+ text = "... " + text
+ }
+ return label + ": " + text
+}
+
+func setupCommandFailure(err error, stdout, stderr *commandOutputTail) error {
+ stderrDetail := stderr.Detail("stderr")
+ stdoutDetail := stdout.Detail("stdout")
+ switch {
+ case stderrDetail != "" && stdoutDetail != "":
+ return fmt.Errorf("%w; %s; %s", err, stderrDetail, stdoutDetail)
+ case stderrDetail != "":
+ return fmt.Errorf("%w; %s", err, stderrDetail)
+ case stdoutDetail != "":
+ return fmt.Errorf("%w; %s", err, stdoutDetail)
+ default:
+ return err
+ }
+}
diff --git a/internal/runtime/setupcommand_test.go b/internal/runtime/setupcommand_test.go
new file mode 100644
index 0000000000..de79ed1028
--- /dev/null
+++ b/internal/runtime/setupcommand_test.go
@@ -0,0 +1,142 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestCommandOutputTail pins the bounded-tail capture RunSetupCommand folds
+// into setup-command failure messages. Copied from the tmux package with the
+// extraction of its runSetupCommand core; the original still lives at
+// internal/runtime/tmux/startup_test.go until tmux delegates here.
+func TestCommandOutputTail(t *testing.T) {
+ cases := []struct {
+ name string
+ limit int
+ writes []string
+ label string
+ want string
+ }{
+ {name: "no output", limit: 8, writes: nil, label: "stderr", want: ""},
+ {name: "whitespace only", limit: 8, writes: []string{" \n\t "}, label: "stderr", want: ""},
+ {name: "under limit", limit: 8, writes: []string{"abc"}, label: "stderr", want: "stderr: abc"},
+ {name: "exact limit has no marker", limit: 4, writes: []string{"abcd"}, label: "stderr", want: "stderr: abcd"},
+ {name: "oversized single write keeps tail", limit: 4, writes: []string{"abcdefgh"}, label: "stderr", want: "stderr: ... efgh"},
+ {name: "rollover across writes", limit: 4, writes: []string{"abc", "def"}, label: "stderr", want: "stderr: ... cdef"},
+ {name: "many small writes", limit: 3, writes: []string{"a", "b", "c", "d", "e"}, label: "stdout", want: "stdout: ... cde"},
+ {name: "zero limit drops content", limit: 0, writes: []string{"abc"}, label: "stderr", want: ""},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tail := newCommandOutputTail(tc.limit)
+ for _, w := range tc.writes {
+ n, err := tail.Write([]byte(w))
+ if err != nil {
+ t.Fatalf("Write(%q) error: %v", w, err)
+ }
+ if n != len(w) {
+ t.Fatalf("Write(%q) = %d, want %d", w, n, len(w))
+ }
+ }
+ if got := tail.Detail(tc.label); got != tc.want {
+ t.Fatalf("Detail(%q) = %q, want %q", tc.label, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestRunSetupCommandUsesGCDIRAsWorkingDirectory pins the cwd contract: the
+// command runs in env["GC_DIR"] when set, so relative paths in a setup
+// command resolve against the session directory.
+func TestRunSetupCommandUsesGCDIRAsWorkingDirectory(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ if err := RunSetupCommand(context.Background(), "pwd > out.txt", map[string]string{
+ "GC_DIR": tmpDir,
+ }, 5*time.Second); err != nil {
+ t.Fatalf("RunSetupCommand: %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(tmpDir, "out.txt"))
+ if err != nil {
+ t.Fatalf("out.txt not created in GC_DIR: %v", err)
+ }
+ // t.TempDir can hand back a symlinked path (macOS /var -> /private/var);
+ // pwd reports the resolved one, so compare resolved forms.
+ wantDir, err := filepath.EvalSymlinks(tmpDir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", tmpDir, err)
+ }
+ gotDir, err := filepath.EvalSymlinks(strings.TrimSpace(string(data)))
+ if err != nil {
+ t.Fatalf("EvalSymlinks(%q): %v", strings.TrimSpace(string(data)), err)
+ }
+ if gotDir != wantDir {
+ t.Fatalf("working directory = %q, want %q", gotDir, wantDir)
+ }
+}
+
+// TestRunSetupCommandAppendsEnvOverlay pins that env entries reach the
+// command on top of the inherited process environment.
+func TestRunSetupCommandAppendsEnvOverlay(t *testing.T) {
+ if err := RunSetupCommand(context.Background(), `[ "$GC_TEST_KEY" = v ]`, map[string]string{
+ "GC_TEST_KEY": "v",
+ }, 5*time.Second); err != nil {
+ t.Fatalf("env overlay not visible to command: %v", err)
+ }
+}
+
+// TestRunSetupCommandIncludesStreamDetailsOnFailure pins that a bounded tail
+// of both streams is folded into the failure so operators see why a setup
+// command failed without hunting for logs.
+func TestRunSetupCommandIncludesStreamDetailsOnFailure(t *testing.T) {
+ err := RunSetupCommand(context.Background(), "echo out; echo err >&2; exit 3", nil, 5*time.Second)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "exit status 3") {
+ t.Fatalf("error = %q, want exit status", err)
+ }
+ if !strings.Contains(err.Error(), "stderr: err") {
+ t.Fatalf("error = %q, want stderr detail", err)
+ }
+ if !strings.Contains(err.Error(), "stdout: out") {
+ t.Fatalf("error = %q, want stdout detail", err)
+ }
+}
+
+// TestRunSetupCommandTimeoutMatchesDeadlineExceeded pins that a command
+// exceeding its per-command timeout reports an error callers can match with
+// errors.Is(err, context.DeadlineExceeded).
+func TestRunSetupCommandTimeoutMatchesDeadlineExceeded(t *testing.T) {
+ err := RunSetupCommand(context.Background(), "sleep 5", nil, 100*time.Millisecond)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("error = %q, want errors.Is DeadlineExceeded", err)
+ }
+}
+
+// TestRunSetupCommandBackgroundChildSucceedsBounded is the regression for
+// setup commands that daemonize a child inheriting stdio: without
+// Cmd.WaitDelay the capture pipes never reach EOF and Run blocks until the
+// descendant exits, far past the timeout. The command itself exits 0, so it
+// must be reported as success once setupCommandWaitDelay force-closes the
+// pipes.
+func TestRunSetupCommandBackgroundChildSucceedsBounded(t *testing.T) {
+ start := time.Now()
+ err := RunSetupCommand(context.Background(), "sleep 30 & exit 0", nil, 30*time.Second)
+ elapsed := time.Since(start)
+ if elapsed >= 10*time.Second {
+ t.Fatalf("RunSetupCommand blocked %v on a background child holding stdio", elapsed)
+ }
+ if err != nil {
+ t.Fatalf("daemonizing setup command exiting 0 should succeed, got %v", err)
+ }
+}
diff --git a/internal/runtime/t3bridge/provider.go b/internal/runtime/t3bridge/provider.go
index e689e88c1a..5c8a8da6ed 100644
--- a/internal/runtime/t3bridge/provider.go
+++ b/internal/runtime/t3bridge/provider.go
@@ -1983,12 +1983,17 @@ func (p *Provider) IsRunning(name string) bool {
}
// ListRunning enumerates live GC-managed session names from the T3 snapshot.
+//
+// A soft-unavailable snapshot is a total observation failure, not proof that
+// no sessions are running. Report ErrRuntimeUnavailable so absence-consuming
+// callers defer instead of treating a transient bridge outage (or an
+// initializing session) as an authoritative empty list.
func (p *Provider) ListRunning(prefix string) ([]string, error) {
snapshot, err := p.rpcSnapshot()
if err != nil {
if isSoftBridgeUnavailable(err) {
fmt.Fprintf(os.Stderr, "t3bridge: ListRunning(%s) — soft-unavailable: %v\n", prefix, err)
- return nil, nil
+ return nil, fmt.Errorf("%w: t3bridge snapshot unavailable: %w", runtime.ErrRuntimeUnavailable, err)
}
return nil, err
}
diff --git a/internal/runtime/t3bridge/provider_test.go b/internal/runtime/t3bridge/provider_test.go
index 1454660f35..201ca3393c 100644
--- a/internal/runtime/t3bridge/provider_test.go
+++ b/internal/runtime/t3bridge/provider_test.go
@@ -1003,3 +1003,38 @@ func TestResolveConfigProviderModel_PrefersStoredEnvelopeIntent(t *testing.T) {
t.Fatalf("model = %q, want gpt-5.4-mini", model)
}
}
+
+// A transiently unreachable bridge is a failed observation, not an
+// authoritative claim that no T3 sessions are running.
+func TestListRunningSoftUnavailableIsRuntimeUnavailable(t *testing.T) {
+ resetBridgeAuthCacheForTest(t)
+ oldDefaults := defaultWSURLCandidates
+ defaultWSURLCandidates = nil
+ t.Cleanup(func() {
+ defaultWSURLCandidates = oldDefaults
+ })
+
+ t.Setenv("T3_BEARER_TOKEN", "test-bearer")
+ t.Setenv("T3_HOME", t.TempDir())
+ t.Setenv("T3_WS_URL", "ws://127.0.0.1:1/ws")
+ t.Setenv("GC_T3BRIDGE_STATE_DIR", t.TempDir())
+
+ p := &Provider{
+ watchers: make(map[string]context.CancelFunc),
+ recentStarts: make(map[string]time.Time),
+ }
+
+ names, err := p.ListRunning("")
+ if err == nil {
+ t.Fatalf("ListRunning during bridge outage returned (%v, nil); empty success would be read as authoritative absence", names)
+ }
+ if !errors.Is(err, runtime.ErrRuntimeUnavailable) {
+ t.Fatalf("ListRunning error = %v, want errors.Is(runtime.ErrRuntimeUnavailable)", err)
+ }
+ if runtime.IsPartialListError(err) {
+ t.Fatalf("ListRunning error = %v, want total observation failure rather than partial usable results", err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("ListRunning names = %v, want none alongside total observation failure", names)
+ }
+}
diff --git a/internal/runtime/tmux/adapter.go b/internal/runtime/tmux/adapter.go
index ec33db17c8..1ceae848fa 100644
--- a/internal/runtime/tmux/adapter.go
+++ b/internal/runtime/tmux/adapter.go
@@ -91,6 +91,9 @@ func (p *Provider) Start(ctx context.Context, name string, cfg runtime.Config) e
p.cache.Invalidate()
return nil
}
+ if errors.Is(err, ErrServerDegraded) {
+ return err
+ }
p.cleanupFailedStart(name, cfg)
return err
}
diff --git a/internal/runtime/tmux/nudge_poke_hidden_test.go b/internal/runtime/tmux/nudge_poke_hidden_test.go
new file mode 100644
index 0000000000..2656a40706
--- /dev/null
+++ b/internal/runtime/tmux/nudge_poke_hidden_test.go
@@ -0,0 +1,95 @@
+package tmux
+
+import (
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gastownhall/gascity/internal/runtime"
+)
+
+// recordingWriteCloser captures the keystrokes gc injects into a hidden attach
+// client so a test can confirm the hidden-injection branch actually ran.
+type recordingWriteCloser struct {
+ mu sync.Mutex
+ buf strings.Builder
+}
+
+func (w *recordingWriteCloser) Write(p []byte) (int, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.Write(p)
+}
+
+func (w *recordingWriteCloser) Close() error { return nil }
+
+func (w *recordingWriteCloser) written() string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.String()
+}
+
+// TestNudgeNowHiddenAttachedRecordsPoke covers the codex-flagged residual of the
+// #4187 nudge-path poke fix: NudgeNow's hidden-attached-client branch
+// (sendHiddenAttachedText) injects gc's own keystrokes just like NudgeSession,
+// so it must record a poke. Before the fix it returned without one, so
+// GetSessionActivity counted gc's own injected input — e.g. the detached-gemini
+// "/rewind" + Enter that ResetInterruptedTurn sends through a hidden client — as
+// the agent responding, masking an unresponsive session.
+//
+// This drives Provider.NudgeNow with an injected hidden client and a fake
+// executor, then verifies the recorded poke discounts a post-grace echo back to
+// the genuine pre-nudge activity. It uses synthetic times (no real tmux, no
+// sleeps) like the other poke unit tests, so it stays in the default lane.
+func TestNudgeNowHiddenAttachedRecordsPoke(t *testing.T) {
+ genuine := time.Date(2026, 6, 4, 1, 0, 0, 0, time.UTC) // last real agent turn
+
+ // rawSessionActivity reads list-windows #{window_activity}; return the
+ // genuine turn's unix seconds so pokePrior snapshots it as the poke's prior.
+ fe := &fakeExecutor{out: strconv.FormatInt(genuine.Unix(), 10)}
+ tm := NewTmux()
+ tm.exec = fe
+ tm.cfg.DebounceMs = 0 // no wall-clock debounce in a unit test
+
+ const sess = "hidden-attach-nudge"
+ sink := &recordingWriteCloser{}
+ tm.hiddenAttachMu.Lock()
+ tm.hiddenAttachClients = map[string]*hiddenAttachClient{
+ sess: {stdin: sink},
+ }
+ tm.hiddenAttachMu.Unlock()
+
+ p := &Provider{tm: tm}
+ if err := p.NudgeNow(sess, runtime.TextContent("/rewind")); err != nil {
+ t.Fatalf("NudgeNow: %v", err)
+ }
+
+ // The hidden-injection branch must have run (not the NudgeSession fallback).
+ if got := sink.written(); !strings.Contains(got, "/rewind") || !strings.Contains(got, "\r") {
+ t.Fatalf("hidden client received %q, want the /rewind text and a trailing Enter", got)
+ }
+
+ tm.pokeMu.Lock()
+ pk, ok := tm.pokes[sess]
+ tm.pokeMu.Unlock()
+ if !ok {
+ t.Fatal("NudgeNow via a hidden attached client recorded no poke; gc's own keystrokes will inflate last_active")
+ }
+ if !pk.prior.Equal(genuine) {
+ t.Fatalf("poke prior = %v, want the genuine pre-nudge activity %v", pk.prior, genuine)
+ }
+ if pk.at.IsZero() {
+ t.Fatal("poke was stamped with a zero time; want it stamped after delivery")
+ }
+
+ // Behavioral consequence the review requires: once the grace elapses with
+ // only gc's own keystroke echo as window activity, the discount must reveal
+ // the genuine pre-nudge activity, not gc's echo. Drive the pure discount with
+ // the recorded poke and a synthetic now so the assertion stays deterministic.
+ echo := pk.at // window_activity is only the nudge's own keystroke echo
+ if got := discountPokeActivity(echo, pk, pk.at.Add(pokeGrace+time.Second)); !got.Equal(genuine) {
+ t.Errorf("post-grace unanswered hidden nudge resolved to %v, want the genuine prior %v", got, genuine)
+ }
+}
diff --git a/internal/runtime/tmux/nudge_poke_integration_test.go b/internal/runtime/tmux/nudge_poke_integration_test.go
index 94e223d162..0ec48fac71 100644
--- a/internal/runtime/tmux/nudge_poke_integration_test.go
+++ b/internal/runtime/tmux/nudge_poke_integration_test.go
@@ -1,3 +1,5 @@
+//go:build integration
+
package tmux
import (
diff --git a/internal/runtime/tmux/server_probe_test.go b/internal/runtime/tmux/server_probe_test.go
index c032f275d6..2970f5eec2 100644
--- a/internal/runtime/tmux/server_probe_test.go
+++ b/internal/runtime/tmux/server_probe_test.go
@@ -4,7 +4,11 @@ import (
"context"
"errors"
"fmt"
+ "net"
+ "os"
+ "path/filepath"
"strings"
+ "syscall"
"testing"
"time"
)
@@ -29,6 +33,338 @@ func firstArgsContainHasSession(args []string) bool {
return false
}
+func TestNewSessionErrNoServerRefusesObservedLiveNamedSocket(t *testing.T) {
+ variants := []struct {
+ name string
+ call func(*Tmux) error
+ }{
+ {name: "NewSession", call: func(tm *Tmux) error {
+ return tm.NewSession("gc-live-socket", "")
+ }},
+ {name: "NewSessionWithCommand", call: func(tm *Tmux) error {
+ return tm.NewSessionWithCommand("gc-live-socket", "", "true")
+ }},
+ {name: "NewSessionWithCommandAndEnv", call: func(tm *Tmux) error {
+ return tm.NewSessionWithCommandAndEnv("gc-live-socket", "", "true", map[string]string{"X": "1"})
+ }},
+ }
+ for _, variant := range variants {
+ t.Run(variant.name, func(t *testing.T) {
+ socketName := "gc-live-socket"
+ tmuxTmpDir := "/tmux-private"
+ t.Setenv("TMUX_TMPDIR", tmuxTmpDir)
+ socketPath := filepath.Join(tmuxTmpDir, fmt.Sprintf("tmux-%d", os.Getuid()), socketName)
+ observerCalls := 0
+ fe := &fakeExecutor{err: ErrNoServer}
+ tm := &Tmux{
+ cfg: Config{SocketName: socketName},
+ exec: fe,
+ serverSocketObserver: func(ctx context.Context, gotPath string) error {
+ observerCalls++
+ if ctx.Err() != nil {
+ t.Fatalf("observer context unexpectedly canceled: %v", ctx.Err())
+ }
+ if gotPath != socketPath {
+ t.Fatalf("observer path = %q, want %q", gotPath, socketPath)
+ }
+ return fmt.Errorf("live socket path=%s inode=97 peer_pid=4242", gotPath)
+ },
+ }
+ err := variant.call(tm)
+ if !errors.Is(err, ErrServerDegraded) {
+ t.Fatalf("err = %v, want ErrServerDegraded", err)
+ }
+ if errors.Is(err, ErrNoServer) {
+ t.Fatalf("err = %v, must not wrap ErrNoServer", err)
+ }
+ for _, want := range []string{
+ "protocol=no-server",
+ "path=" + socketPath,
+ "inode=97",
+ "peer_pid=4242",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("err = %q, want %q", err, want)
+ }
+ }
+ if observerCalls != 1 {
+ t.Fatalf("observer calls = %d, want 1", observerCalls)
+ }
+ if len(fe.calls) != 1 || !firstArgsContainHasSession(fe.calls[0]) {
+ t.Fatalf("calls = %#v, want exactly the preflight has-session probe", fe.calls)
+ }
+ })
+ }
+}
+
+func TestNewSessionErrNoServerObservedSafeAllowsCreation(t *testing.T) {
+ for _, observation := range []struct {
+ name string
+ err error
+ }{
+ {name: "absent"},
+ {name: "stable-refused"},
+ } {
+ t.Run(observation.name, func(t *testing.T) {
+ fe := probeAssertSet([]string{"", "", ""}, []error{ErrNoServer, nil, nil})
+ observerCalls := 0
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: fe,
+ serverSocketObserver: func(context.Context, string) error {
+ observerCalls++
+ return observation.err
+ },
+ }
+
+ if err := tm.NewSession("gc-fresh", ""); err != nil {
+ t.Fatalf("NewSession: %v", err)
+ }
+ if observerCalls != 1 {
+ t.Fatalf("observer calls = %d, want 1", observerCalls)
+ }
+ if len(fe.calls) < 2 || fe.calls[1][3] != "new-session" {
+ t.Fatalf("calls = %#v, want probe followed by new-session", fe.calls)
+ }
+ })
+ }
+}
+
+func TestNewSessionErrNoServerUnknownObservationFailsClosed(t *testing.T) {
+ t.Run("unknown observer", func(t *testing.T) {
+ fe := &fakeExecutor{err: ErrNoServer}
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: fe,
+ serverSocketObserver: func(context.Context, string) error {
+ return errors.New("socket observation failed")
+ },
+ }
+
+ err := tm.NewSession("gc-unknown-observation", "")
+ if !errors.Is(err, ErrServerDegraded) {
+ t.Fatalf("err = %v, want ErrServerDegraded", err)
+ }
+ if errors.Is(err, ErrNoServer) {
+ t.Fatalf("err = %v, must not wrap ErrNoServer", err)
+ }
+ if len(fe.calls) != 1 {
+ t.Fatalf("calls = %#v, want only the preflight probe", fe.calls)
+ }
+ })
+
+ socketInfo := func(t *testing.T) os.FileInfo {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "socket-fixture")
+ if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
+ t.Fatalf("write socket fixture: %v", err)
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ t.Fatalf("lstat socket fixture: %v", err)
+ }
+ return socketModeFileInfo{FileInfo: info}
+ }
+ dialUnexpected := func(context.Context, string) (net.Conn, error) {
+ return nil, errors.New("unexpected dial failure")
+ }
+
+ t.Run("non-socket", func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "plain-file")
+ if err := os.WriteFile(path, []byte("fixture"), 0o600); err != nil {
+ t.Fatalf("write plain fixture: %v", err)
+ }
+ err := observeNamedSocketWith(context.Background(), path, os.Lstat, dialUnexpected)
+ if err == nil || !strings.Contains(err.Error(), "reason=not-unix-socket") {
+ t.Fatalf("observe non-socket error = %v, want non-socket refusal", err)
+ }
+ })
+
+ t.Run("initial permission failure", func(t *testing.T) {
+ err := observeNamedSocketWith(context.Background(), "permission-denied", func(string) (os.FileInfo, error) {
+ return nil, os.ErrPermission
+ }, dialUnexpected)
+ if err == nil || !strings.Contains(err.Error(), "lstat=") {
+ t.Fatalf("observe permission failure = %v, want lstat refusal", err)
+ }
+ })
+
+ t.Run("unexpected dial failure", func(t *testing.T) {
+ info := socketInfo(t)
+ err := observeNamedSocketWith(context.Background(), "unexpected-dial", func(string) (os.FileInfo, error) {
+ return info, nil
+ }, dialUnexpected)
+ if err == nil || !strings.Contains(err.Error(), "unexpected dial failure") {
+ t.Fatalf("observe unexpected dial failure = %v, want fail closed", err)
+ }
+ })
+
+ t.Run("dial cancellation fails closed", func(t *testing.T) {
+ info := socketInfo(t)
+ for _, dialErr := range []error{context.Canceled, context.DeadlineExceeded} {
+ err := observeNamedSocketWith(context.Background(), "dial-canceled", func(string) (os.FileInfo, error) {
+ return info, nil
+ }, func(context.Context, string) (net.Conn, error) {
+ return nil, dialErr
+ })
+ if err == nil || !strings.Contains(err.Error(), dialErr.Error()) {
+ t.Fatalf("observe dial %v = %v, want fail closed", dialErr, err)
+ }
+ }
+ })
+
+ t.Run("post-lstat identity replacement", func(t *testing.T) {
+ first := socketInfo(t)
+ second := socketInfo(t)
+ calls := 0
+ err := observeNamedSocketWith(context.Background(), "identity-replaced", func(string) (os.FileInfo, error) {
+ calls++
+ if calls == 1 {
+ return first, nil
+ }
+ return second, nil
+ }, func(context.Context, string) (net.Conn, error) {
+ return nil, syscall.ECONNREFUSED
+ })
+ if err == nil || !strings.Contains(err.Error(), "socket-identity-changed") {
+ t.Fatalf("observe identity replacement = %v, want fail closed", err)
+ }
+ })
+
+ t.Run("already canceled context skips lstat", func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ called := false
+ err := observeNamedSocketWith(ctx, "canceled-before-lstat", func(string) (os.FileInfo, error) {
+ called = true
+ return nil, nil
+ }, dialUnexpected)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("observe canceled context = %v, want context canceled", err)
+ }
+ if called {
+ t.Fatal("lstat ran after context cancellation")
+ }
+ })
+
+ t.Run("blocking lstat returns on cancellation", func(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ finished := make(chan struct{})
+ result := make(chan error, 1)
+ go func() {
+ result <- observeNamedSocketWith(ctx, "blocking-lstat", func(string) (os.FileInfo, error) {
+ close(entered)
+ <-release
+ close(finished)
+ return nil, os.ErrNotExist
+ }, dialUnexpected)
+ }()
+ <-entered
+ cancel()
+ if err := <-result; !errors.Is(err, context.Canceled) {
+ t.Fatalf("observe canceled blocking lstat = %v, want context canceled", err)
+ }
+ close(release)
+ <-finished
+ })
+}
+
+type socketModeFileInfo struct{ os.FileInfo }
+
+func (info socketModeFileInfo) Mode() os.FileMode { return info.FileInfo.Mode() | os.ModeSocket }
+
+func TestProbeServerAliveHealthyProtocolDoesNotObserveSocket(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ err error
+ }{
+ {name: "success"},
+ {name: "session-not-found", err: ErrSessionNotFound},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ observerCalls := 0
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: &fakeExecutor{err: tc.err},
+ serverSocketObserver: func(context.Context, string) error {
+ observerCalls++
+ return errors.New("observer must not run")
+ },
+ }
+
+ if err := tm.probeServerAlive(); err != nil {
+ t.Fatalf("probeServerAlive: %v", err)
+ }
+ if observerCalls != 0 {
+ t.Fatalf("observer calls = %d, want 0", observerCalls)
+ }
+ })
+ }
+}
+
+func TestProbeServerAliveUnknownProtocolDoesNotObserveSocket(t *testing.T) {
+ observerCalls := 0
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: &fakeExecutor{err: errors.New("tmux protocol failure")},
+ serverSocketObserver: func(context.Context, string) error {
+ observerCalls++
+ return nil
+ },
+ }
+
+ err := tm.probeServerAlive()
+ if !errors.Is(err, ErrServerDegraded) {
+ t.Fatalf("probeServerAlive error = %v, want ErrServerDegraded", err)
+ }
+ if observerCalls != 0 {
+ t.Fatalf("observer calls = %d, want 0", observerCalls)
+ }
+}
+
+// TestProbeServerAliveAcceptsEmptyLiveServer pins the drained-server case:
+// tmux answers "no current target" when the server is alive but holds zero
+// sessions (gc's normal state, because ConfigureServer sets exit-empty off).
+// The server answered, so new-session attaches rather than unlink+rebind —
+// the preflight must proceed without observing the socket at all.
+func TestProbeServerAliveAcceptsEmptyLiveServer(t *testing.T) {
+ observerCalls := 0
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: &fakeExecutor{err: ErrNoCurrentTarget},
+ serverSocketObserver: func(context.Context, string) error {
+ observerCalls++
+ return errors.New("observer must not run")
+ },
+ }
+
+ if err := tm.probeServerAlive(); err != nil {
+ t.Fatalf("probeServerAlive: %v", err)
+ }
+ if observerCalls != 0 {
+ t.Fatalf("observer calls = %d, want 0", observerCalls)
+ }
+}
+
+func TestNamedSocketPathUsesTMUXTMPDIRAndIgnoresTMPDIR(t *testing.T) {
+ t.Setenv("TMUX_TMPDIR", "/tmux-private")
+ t.Setenv("TMPDIR", "/must-not-be-used")
+ if got, want := namedSocketPath("gc-test"), filepath.Join("/tmux-private", fmt.Sprintf("tmux-%d", os.Getuid()), "gc-test"); got != want {
+ t.Fatalf("namedSocketPath() = %q, want %q", got, want)
+ }
+}
+
+func TestNamedSocketPathFallsBackToTmpWhenTMUXTMPDIREmpty(t *testing.T) {
+ t.Setenv("TMUX_TMPDIR", "")
+ t.Setenv("TMPDIR", "/must-not-be-used")
+ if got, want := namedSocketPath("gc-test"), filepath.Join("/tmp", fmt.Sprintf("tmux-%d", os.Getuid()), "gc-test"); got != want {
+ t.Fatalf("namedSocketPath() = %q, want %q", got, want)
+ }
+}
+
func TestNewSessionSkipsProbeWhenSocketEmpty(t *testing.T) {
fe := &fakeExecutor{}
tm := NewTmux()
@@ -79,7 +415,13 @@ func TestNewSessionProceedsWhenProbeReportsNoServer(t *testing.T) {
[]string{"", "", ""},
[]error{ErrNoServer, nil, nil},
)
- tm := &Tmux{cfg: Config{SocketName: "gc-test"}, exec: fe}
+ tm := &Tmux{
+ cfg: Config{SocketName: "gc-test"},
+ exec: fe,
+ serverSocketObserver: func(context.Context, string) error {
+ return nil
+ },
+ }
if err := tm.NewSession("gc-fresh", ""); err != nil {
t.Fatalf("NewSession: %v", err)
@@ -153,7 +495,6 @@ func TestProbeServerAliveAcceptsHealthyServer(t *testing.T) {
err error
}{
{name: "ErrSessionNotFound", err: ErrSessionNotFound},
- {name: "ErrNoServer", err: ErrNoServer},
{name: "nil", err: nil},
}
for _, tc := range cases {
diff --git a/internal/runtime/tmux/server_socket_probe.go b/internal/runtime/tmux/server_socket_probe.go
new file mode 100644
index 0000000000..2929f6d4ad
--- /dev/null
+++ b/internal/runtime/tmux/server_socket_probe.go
@@ -0,0 +1,112 @@
+package tmux
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// namedSocketPath resolves the exact path tmux uses for a named -L socket.
+// tmux honors TMUX_TMPDIR here; TMPDIR is deliberately not a fallback.
+func namedSocketPath(socketName string) string {
+ tmpDir := os.Getenv("TMUX_TMPDIR")
+ if tmpDir == "" {
+ tmpDir = "/tmp"
+ }
+ return filepath.Join(tmpDir, fmt.Sprintf("tmux-%d", os.Getuid()), socketName)
+}
+
+// observeNamedSocket distinguishes a safely absent or stale named socket from
+// a socket that might still belong to a live server. It fails closed whenever
+// its filesystem and dial observations cannot prove it is safe to create.
+func observeNamedSocket(ctx context.Context, path string) error {
+ return observeNamedSocketWith(ctx, path, os.Lstat, func(ctx context.Context, path string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", path)
+ })
+}
+
+// observeNamedSocketWith keeps the socket policy testable without opening a
+// listener. The lstat calls are context-bounded from the caller's perspective:
+// an OS syscall already in progress cannot be canceled, but its buffered result
+// cannot hold the caller after the context ends.
+func observeNamedSocketWith(
+ ctx context.Context,
+ path string,
+ lstat func(string) (os.FileInfo, error),
+ dial func(context.Context, string) (net.Conn, error),
+) error {
+ before, err := lstatWithContext(ctx, lstat, path)
+ if contextErr := ctx.Err(); contextErr != nil {
+ return fmt.Errorf("path=%s inode=unknown peer_pid=unknown lstat=%w", path, contextErr)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("path=%s inode=unknown peer_pid=unknown lstat=%w", path, err)
+ }
+ inode := socketInode(before)
+ if before.Mode()&os.ModeSocket == 0 {
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown reason=not-unix-socket", path, inode)
+ }
+
+ conn, err := dial(ctx, path)
+ if err == nil {
+ defer func() { _ = conn.Close() }()
+ unixConn, ok := conn.(*net.UnixConn)
+ if !ok {
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown reason=unexpected-connection-type-%T", path, inode, conn)
+ }
+ peerPID, peerErr := socketPeerPID(unixConn)
+ if peerErr != nil {
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown peer_pid_reason=%w", path, inode, peerErr)
+ }
+ return fmt.Errorf("path=%s inode=%s peer_pid=%d reason=live-unix-socket", path, inode, peerPID)
+ }
+
+ after, afterErr := lstatWithContext(ctx, lstat, path)
+ if contextErr := ctx.Err(); contextErr != nil {
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown post_lstat=%w", path, inode, contextErr)
+ }
+ pathAbsent := errors.Is(afterErr, os.ErrNotExist)
+ stable := afterErr == nil && os.SameFile(before, after)
+ if errors.Is(err, syscall.ECONNREFUSED) && (pathAbsent || stable) {
+ return nil
+ }
+ if errors.Is(err, os.ErrNotExist) && pathAbsent {
+ return nil
+ }
+ if afterErr != nil {
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown dial=%w post_lstat=%w", path, inode, err, afterErr)
+ }
+ return fmt.Errorf("path=%s inode=%s peer_pid=unknown dial=%w post_inode=%s reason=socket-identity-changed-or-dial-failed", path, inode, err, socketInode(after))
+}
+
+type lstatResult struct {
+ info os.FileInfo
+ err error
+}
+
+func lstatWithContext(ctx context.Context, lstat func(string) (os.FileInfo, error), path string) (os.FileInfo, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ result := make(chan lstatResult, 1)
+ go func() {
+ info, err := lstat(path)
+ result <- lstatResult{info: info, err: err}
+ }()
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case result := <-result:
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ return result.info, result.err
+ }
+}
diff --git a/internal/runtime/tmux/server_socket_probe_darwin.go b/internal/runtime/tmux/server_socket_probe_darwin.go
new file mode 100644
index 0000000000..4bebb76c0f
--- /dev/null
+++ b/internal/runtime/tmux/server_socket_probe_darwin.go
@@ -0,0 +1,40 @@
+//go:build darwin
+
+package tmux
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func socketInode(info os.FileInfo) string {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return "unknown"
+ }
+ return strconv.FormatUint(stat.Ino, 10)
+}
+
+func socketPeerPID(conn *net.UnixConn) (int, error) {
+ rawConn, err := conn.SyscallConn()
+ if err != nil {
+ return 0, fmt.Errorf("get raw connection: %w", err)
+ }
+ var peerPID int
+ var controlErr error
+ err = rawConn.Control(func(fd uintptr) {
+ peerPID, controlErr = unix.GetsockoptInt(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERPID)
+ })
+ if err != nil {
+ return 0, fmt.Errorf("inspect socket: %w", err)
+ }
+ if controlErr != nil {
+ return 0, fmt.Errorf("read LOCAL_PEERPID: %w", controlErr)
+ }
+ return peerPID, nil
+}
diff --git a/internal/runtime/tmux/server_socket_probe_linux.go b/internal/runtime/tmux/server_socket_probe_linux.go
new file mode 100644
index 0000000000..bcc4557982
--- /dev/null
+++ b/internal/runtime/tmux/server_socket_probe_linux.go
@@ -0,0 +1,45 @@
+//go:build linux
+
+package tmux
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func socketInode(info os.FileInfo) string {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return "unknown"
+ }
+ return strconv.FormatUint(stat.Ino, 10)
+}
+
+func socketPeerPID(conn *net.UnixConn) (int, error) {
+ rawConn, err := conn.SyscallConn()
+ if err != nil {
+ return 0, fmt.Errorf("get raw connection: %w", err)
+ }
+ var peerPID int
+ var controlErr error
+ err = rawConn.Control(func(fd uintptr) {
+ cred, credErr := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
+ if credErr != nil {
+ controlErr = credErr
+ return
+ }
+ peerPID = int(cred.Pid)
+ })
+ if err != nil {
+ return 0, fmt.Errorf("inspect socket: %w", err)
+ }
+ if controlErr != nil {
+ return 0, fmt.Errorf("read SO_PEERCRED: %w", controlErr)
+ }
+ return peerPID, nil
+}
diff --git a/internal/runtime/tmux/server_socket_probe_other.go b/internal/runtime/tmux/server_socket_probe_other.go
new file mode 100644
index 0000000000..ba57c12c90
--- /dev/null
+++ b/internal/runtime/tmux/server_socket_probe_other.go
@@ -0,0 +1,15 @@
+//go:build !linux && !darwin
+
+package tmux
+
+import (
+ "fmt"
+ "net"
+ "os"
+)
+
+func socketInode(os.FileInfo) string { return "unknown" }
+
+func socketPeerPID(*net.UnixConn) (int, error) {
+ return 0, fmt.Errorf("peer PID lookup is unsupported on this platform")
+}
diff --git a/internal/runtime/tmux/tmux.go b/internal/runtime/tmux/tmux.go
index 3ae36c81e7..94f0fb55f9 100644
--- a/internal/runtime/tmux/tmux.go
+++ b/internal/runtime/tmux/tmux.go
@@ -154,6 +154,12 @@ var (
ErrServerDegraded = errors.New("tmux server degraded: refusing new-session to avoid socket clobber")
)
+// ErrNoCurrentTarget is tmux's reply when the server IS alive but holds no
+// sessions (exit-empty off — gc's configured default). It wraps ErrNoServer so
+// existing idempotent-teardown callers are unchanged; only the new-session
+// preflight distinguishes it.
+var ErrNoCurrentTarget = fmt.Errorf("%w: no current target", ErrNoServer)
+
const (
hiddenAttachReadyTimeout = 2 * time.Second
hiddenAttachMaxLifetime = 20 * time.Second
@@ -243,6 +249,12 @@ type Tmux struct {
// agentSlice wraps pane commands in a transient systemd user scope when
// GC_AGENT_SLICE is set (see AgentSliceEnv in agent_slice.go).
agentSlice agentSliceWrapper
+
+ // serverSocketObserver observes a named socket only after tmux reports
+ // ErrNoServer during the new-session preflight. Nil selects the production
+ // observer; tests inject a deterministic observation without opening a
+ // socket.
+ serverSocketObserver func(context.Context, string) error
}
// pokeInfo records a gc-initiated send-keys ("poke", e.g. a wake or nudge) to a
@@ -319,9 +331,13 @@ func wrapError(err error, stderr string, args []string) error {
stderr = strings.TrimSpace(stderr)
// Detect specific error types
+ if strings.Contains(stderr, "no current target") {
+ // The server answered — it is simply holding zero sessions. Wraps
+ // ErrNoServer so idempotent-teardown callers are unaffected.
+ return ErrNoCurrentTarget
+ }
if strings.Contains(stderr, "no server running") ||
strings.Contains(stderr, "error connecting to") ||
- strings.Contains(stderr, "no current target") ||
strings.Contains(stderr, "server exited unexpectedly") {
return ErrNoServer
}
@@ -351,8 +367,10 @@ func wrapError(err error, stderr string, args []string) error {
// - nil when SocketName is empty (default-server case is out of scope) or
// when the server replies (alive — including the expected "session not
// found" for the bogus probe target).
-// - nil with ErrNoServer semantics absorbed (no server bound is safe; tmux
-// will create a fresh server cleanly).
+// - nil when tmux reports "no current target" (ErrNoCurrentTarget): the
+// server answered and is alive with zero sessions, so new-session attaches
+// rather than unlinking and rebinding.
+// - nil when ErrNoServer is corroborated by a safely absent or stale socket.
// - ErrServerDegraded when the probe times out or returns any other error,
// indicating the server is in a state where new-session would risk
// clobbering. Callers MUST surface this and refuse to proceed.
@@ -372,11 +390,25 @@ func (t *Tmux) probeServerAlive() error {
// Healthy server, just doesn't have the probe session. Safe.
return nil
}
- if errors.Is(err, ErrNoServer) {
- // No server bound (stale socket or never existed). Safe — tmux will
- // unlink any stale socket and bind a fresh server.
+ if errors.Is(err, ErrNoCurrentTarget) {
+ // The server answered: it is alive with zero sessions, so new-session
+ // attaches rather than unlinking and rebinding. Never a stale socket.
return nil
}
+ if errors.Is(err, ErrNoServer) {
+ observer := t.serverSocketObserver
+ if observer == nil {
+ observer = observeNamedSocket
+ }
+ path := namedSocketPath(t.cfg.SocketName)
+ observationErr := observer(ctx, path)
+ if observationErr == nil {
+ return nil
+ }
+ // Do not wrap ErrNoServer here: callers such as EnsureSessionFresh
+ // must not retry a guarded no-server result as an ordinary absence.
+ return fmt.Errorf("%w: protocol=no-server path=%s observation=%w", ErrServerDegraded, path, observationErr)
+ }
// Timeout, fork failure, or any other unrecognized error: server is in
// an indeterminate state. Refuse to proceed rather than let tmux silently
// fork into a parallel server.
@@ -1589,6 +1621,13 @@ func (t *Tmux) sendHiddenAttachedText(target, text string) (bool, error) {
if text == "" {
return true, nil
}
+ // A hidden attach client injects gc's own keystrokes just like NudgeSession,
+ // so record a poke here too (the residual NudgeNow gap): capture the
+ // pre-nudge activity before the first write and stamp it only after the
+ // trailing Enter is delivered, so a later GetSessionActivity discounts gc's
+ // echo instead of counting this nudge as the agent responding (see
+ // discountPokeActivity). A failed write records nothing.
+ commitPoke := t.beginPoke(target)
if err := client.write([]byte(text)); err != nil {
return true, err
}
@@ -1598,6 +1637,7 @@ func (t *Tmux) sendHiddenAttachedText(target, text string) (bool, error) {
if err := client.write([]byte{'\r'}); err != nil {
return true, err
}
+ commitPoke()
return true, nil
}
@@ -1831,11 +1871,11 @@ func (t *Tmux) NudgeSession(session, message string) error {
// entry would let the final Enter's echo land outside the discount window.
// pokePrior also carries a still-unanswered earlier poke's baseline forward
// so chained nudges inside pokeGrace don't record gc's own echo as prior.
- prior := t.pokePrior(session)
+ commitPoke := t.beginPoke(session)
delivered := false
defer func() {
if delivered {
- t.recordPokeAt(session, prior, time.Now())
+ commitPoke()
}
}()
@@ -1918,11 +1958,11 @@ func (t *Tmux) NudgePane(pane, message string) error {
// See NudgeSession for why prior is captured before the first keystroke
// (via pokePrior, which also carries a still-unanswered earlier poke's
// baseline forward) and the poke stamped only on confirmed delivery.
- prior := t.pokePrior(pane)
+ commitPoke := t.beginPoke(pane)
delivered := false
defer func() {
if delivered {
- t.recordPokeAt(pane, prior, time.Now())
+ commitPoke()
}
}()
@@ -2375,6 +2415,20 @@ func (t *Tmux) recordPokeAt(session string, prior, at time.Time) {
t.pokeMu.Unlock()
}
+// beginPoke snapshots the genuine pre-nudge activity for session (via pokePrior,
+// which also carries a still-unanswered earlier poke's baseline forward) and
+// returns a commit closure. Callers invoke commit only after the nudge's final
+// keystroke is confirmed delivered; it stamps the poke so a later
+// GetSessionActivity discounts gc's own keystroke echo (see discountPokeActivity)
+// instead of counting the nudge as the agent responding. A nudge that never
+// confirms delivery must not call commit, leaving last_active untouched. This is
+// the shared prior-before-write / stamp-after-delivery contract used by
+// NudgeSession, NudgePane, and the hidden-attached send path.
+func (t *Tmux) beginPoke(session string) (commit func()) {
+ prior := t.pokePrior(session)
+ return func() { t.recordPokeAt(session, prior, time.Now()) }
+}
+
// pokePrior snapshots the genuine session activity to record as a new poke's
// prior. It reads raw window activity but, when an earlier unanswered poke is
// still on record, carries that poke's prior forward (see pokePriorBaseline) so
diff --git a/internal/runtime/tmux/tmux_test.go b/internal/runtime/tmux/tmux_test.go
index 71b753b1a5..149e0a7742 100644
--- a/internal/runtime/tmux/tmux_test.go
+++ b/internal/runtime/tmux/tmux_test.go
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
+ "net"
"os"
"os/exec"
"path/filepath"
@@ -38,6 +39,223 @@ func testTmux() *Tmux {
return NewTmuxWithConfig(cfg)
}
+// noServerPreflightExecutor makes only the first has-session preflight report
+// ErrNoServer, then delegates every other operation to real tmux. It models a
+// stale protocol observation while retaining the real socket boundary.
+type noServerPreflightExecutor struct {
+ used bool
+}
+
+func (e *noServerPreflightExecutor) execute(args []string) (string, error) {
+ return realExecutor{}.execute(args)
+}
+
+func (e *noServerPreflightExecutor) executeCtx(ctx context.Context, args []string) (string, error) {
+ if !e.used && firstArgsContainHasSession(args) {
+ e.used = true
+ return "", ErrNoServer
+ }
+ return realExecutor{}.executeCtx(ctx, args)
+}
+
+func TestNewSessionNoServerProbeDoesNotClobberLiveNamedSocket(t *testing.T) {
+ if !hasTmux() {
+ t.Skip("tmux not installed")
+ }
+
+ newTmux := func(socketName string) *Tmux {
+ cfg := DefaultConfig()
+ cfg.SocketName = socketName
+ return NewTmuxWithConfig(cfg)
+ }
+ newSocketName := func(suffix string) string {
+ return fmt.Sprintf("gctest-live-socket-%s-%d-%d", suffix, os.Getpid(), time.Now().UnixNano())
+ }
+
+ t.Run("live-server-refuses", func(t *testing.T) {
+ tm := newTmux(newSocketName("live"))
+ socketPath := namedSocketPath(tm.cfg.SocketName)
+ t.Cleanup(func() {
+ _ = tm.KillServer()
+ _ = os.Remove(socketPath)
+ })
+
+ const instanceToken = "live-server-instance-token"
+ original := fmt.Sprintf("gc-live-original-%d", time.Now().UnixNano())
+ if err := tm.NewSession(original, ""); err != nil {
+ t.Fatalf("create original session: %v", err)
+ }
+ if err := tm.SetEnvironment(original, "GC_INSTANCE_TOKEN", instanceToken); err != nil {
+ t.Fatalf("seed original instance token: %v", err)
+ }
+ serverPID, err := tm.run("display-message", "-p", "#{pid}")
+ if err != nil {
+ t.Fatalf("read server #{pid}: %v", err)
+ }
+ beforeSocket, err := os.Lstat(socketPath)
+ if err != nil {
+ t.Fatalf("lstat live socket %q: %v", socketPath, err)
+ }
+ beforeSessions, err := tm.ListSessions()
+ if err != nil {
+ t.Fatalf("list original sessions: %v", err)
+ }
+
+ guarded := NewProviderWithConfig(tm.cfg)
+ guarded.Tmux().exec = &noServerPreflightExecutor{}
+ err = guarded.Start(context.Background(), original, runtimepkg.Config{
+ Command: "sleep 600",
+ Env: map[string]string{"GC_INSTANCE_TOKEN": instanceToken},
+ })
+ if !errors.Is(err, ErrServerDegraded) {
+ t.Fatalf("Provider.Start error = %v, want ErrServerDegraded", err)
+ }
+ if errors.Is(err, ErrNoServer) {
+ t.Fatalf("Provider.Start error = %v, must not wrap ErrNoServer", err)
+ }
+ for _, want := range []string{
+ "protocol=no-server",
+ "path=" + socketPath,
+ "inode=" + socketInode(beforeSocket),
+ "peer_pid=" + serverPID,
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("Provider.Start error = %q, want %q", err, want)
+ }
+ }
+
+ hasOriginal, err := tm.HasSession(original)
+ if err != nil {
+ t.Fatalf("check original session: %v", err)
+ }
+ if !hasOriginal {
+ t.Fatalf("original session %q was removed after guarded refusal", original)
+ }
+ afterSessions, err := tm.ListSessions()
+ if err != nil {
+ t.Fatalf("list sessions after guarded refusal: %v", err)
+ }
+ if !reflect.DeepEqual(afterSessions, beforeSessions) {
+ t.Fatalf("sessions after guarded refusal = %v, want %v", afterSessions, beforeSessions)
+ }
+ afterPID, err := tm.run("display-message", "-p", "#{pid}")
+ if err != nil {
+ t.Fatalf("read server #{pid} after guarded refusal: %v", err)
+ }
+ if afterPID != serverPID {
+ t.Fatalf("server pid after guarded refusal = %q, want %q", afterPID, serverPID)
+ }
+ afterSocket, err := os.Lstat(socketPath)
+ if err != nil {
+ t.Fatalf("lstat socket after guarded refusal: %v", err)
+ }
+ if !os.SameFile(beforeSocket, afterSocket) {
+ t.Fatalf("socket inode changed: before=%s after=%s", socketInode(beforeSocket), socketInode(afterSocket))
+ }
+ })
+
+ t.Run("absent-allows-cold-creation", func(t *testing.T) {
+ tm := newTmux(newSocketName("absent"))
+ socketPath := namedSocketPath(tm.cfg.SocketName)
+ t.Cleanup(func() {
+ _ = tm.KillServer()
+ _ = os.Remove(socketPath)
+ })
+ if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("remove prior socket %q: %v", socketPath, err)
+ }
+
+ session := fmt.Sprintf("gc-absent-socket-%d", time.Now().UnixNano())
+ if err := tm.NewSession(session, ""); err != nil {
+ t.Fatalf("NewSession with absent socket: %v", err)
+ }
+ has, err := tm.HasSession(session)
+ if err != nil || !has {
+ t.Fatalf("created session present = %t, err = %v", has, err)
+ }
+ })
+
+ t.Run("stale-refused-allows-cold-creation", func(t *testing.T) {
+ tm := newTmux(newSocketName("stale"))
+ socketPath := namedSocketPath(tm.cfg.SocketName)
+ t.Cleanup(func() {
+ _ = tm.KillServer()
+ _ = os.Remove(socketPath)
+ })
+ if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
+ t.Fatalf("create socket directory: %v", err)
+ }
+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
+ if err != nil {
+ t.Fatalf("create stale socket: %v", err)
+ }
+ listener.SetUnlinkOnClose(false)
+ if err := listener.Close(); err != nil {
+ t.Fatalf("close stale socket listener: %v", err)
+ }
+
+ session := fmt.Sprintf("gc-stale-socket-%d", time.Now().UnixNano())
+ if err := tm.NewSession(session, ""); err != nil {
+ t.Fatalf("NewSession with stale refused socket: %v", err)
+ }
+ has, err := tm.HasSession(session)
+ if err != nil || !has {
+ t.Fatalf("created session present = %t, err = %v", has, err)
+ }
+ })
+}
+
+// TestNewSessionSucceedsOnDrainedLiveServer covers gc's normal drained state:
+// exit-empty is off, so killing the last session leaves the server alive with
+// zero sessions and the socket still bound. tmux answers the preflight probe
+// with "no current target" — the server DID answer, so new-session attaches
+// rather than unlinking and rebinding, and creation must succeed.
+func TestNewSessionSucceedsOnDrainedLiveServer(t *testing.T) {
+ if !hasTmux() {
+ t.Skip("tmux not installed")
+ }
+
+ cfg := DefaultConfig()
+ cfg.SocketName = fmt.Sprintf("gctest-drained-%d-%d", os.Getpid(), time.Now().UnixNano())
+ tm := NewTmuxWithConfig(cfg)
+ socketPath := namedSocketPath(cfg.SocketName)
+ t.Cleanup(func() {
+ _ = tm.KillServer()
+ _ = os.Remove(socketPath)
+ })
+
+ first := fmt.Sprintf("gc-drained-first-%d", time.Now().UnixNano())
+ if err := tm.NewSession(first, ""); err != nil {
+ t.Fatalf("create first session: %v", err)
+ }
+ if err := tm.SetExitEmpty(false); err != nil {
+ t.Fatalf("SetExitEmpty(false): %v", err)
+ }
+ if err := tm.KillSession(first); err != nil {
+ t.Fatalf("kill last session: %v", err)
+ }
+
+ sessions, err := tm.ListSessions()
+ if err != nil {
+ t.Fatalf("list sessions after drain: %v", err)
+ }
+ if len(sessions) != 0 {
+ t.Fatalf("sessions after drain = %v, want none", sessions)
+ }
+ if _, err := os.Lstat(socketPath); err != nil {
+ t.Fatalf("socket %q missing after drain: %v", socketPath, err)
+ }
+
+ second := fmt.Sprintf("gc-drained-second-%d", time.Now().UnixNano())
+ if err := tm.NewSession(second, ""); err != nil {
+ t.Fatalf("NewSession on drained live server: %v", err)
+ }
+ has, err := tm.HasSession(second)
+ if err != nil || !has {
+ t.Fatalf("session created on drained server present = %t, err = %v", has, err)
+ }
+}
+
func ensureTestSocketSession(t *testing.T, tm *Tmux) {
t.Helper()
diff --git a/internal/session/REQUIREMENTS.md b/internal/session/REQUIREMENTS.md
index 9b6af4be32..e10874996f 100644
--- a/internal/session/REQUIREMENTS.md
+++ b/internal/session/REQUIREMENTS.md
@@ -138,6 +138,7 @@ unless the row names how they map to the canonical projection.
| SESSION-RECON-010 | Dead-session exit classification | A dead session is classified through three lanes in order: rate-limit (crash candidate whose provider screen shows a rate-limit message is quarantined with sleep reason `rate_limit`, no crash counted), rapid crash (death inside the stability window records a wake failure and clears `last_woke_at`), churn band (death past stability but before productivity records churn; at or past productivity the churn counter clears). Crash candidacy requires: dead, non-subprocess provider, no pending drain, parseable `last_woke_at`, create lease not in flight. The rapid lanes ignore `pending_create_claim` and `sleep_reason`; the churn lane additionally skips on claim, deliberate sleep reasons, subprocess, and drains. Rate-limit candidacy is not band-limited. | `internal/session/lifecycle_exits.go` (`DecideSessionExit`, `IsDeliberateSleepReason`); `internal/session/lifecycle_exits_test.go`; `cmd/gc/session_reconcile_test.go` (`TestCheckStability_*`, `TestCheckChurn_*`); `cmd/gc/session_reconcile_ratelimit_test.go` |
| SESSION-RECON-011 | Crash and churn accrual | Each rapid crash advances `wake_attempts`; reaching the max quarantines with sleep reason `quarantine`. Each churn event advances `churn_count`; reaching the max quarantines with sleep reason `context-churn`. Both quarantines are metadata-only (no state-machine move). Crash and churn events force a fresh conversation: `session_key` clears and `continuation_reset_pending` is set; wake failures additionally clear `started_config_hash` so the next wake runs as a first start, churn keeps it. Rate-limit backoff sets the session asleep with cleared wake stamp and pending-create markers, without counting a crash or touching conversation metadata. | `internal/session/lifecycle_exits.go` (`WakeFailureAccrualPatch`, `ChurnAccrualPatch`, `ConversationResetPatch`, `RateLimitQuarantinePatch`); `internal/session/lifecycle_exits_test.go`; `cmd/gc/session_reconcile_test.go` (`TestRecordWakeFailure_*`); `cmd/gc/session_reconcile_ratelimit_test.go` |
| SESSION-RECON-012 | Ambiguous controller stop request | Direct session/provider cleanup is allowed only when the controller stop request definitely failed before entry. Once the socket connection succeeds, a missing, partial, malformed, oversized, or otherwise uncertain acknowledgement fails closed so the CLI cannot become a second shutdown owner. | `cmd/gc/controller_stop_client_test.go`; `cmd/gc/cmd_stop_test.go` (`TestCmdStopBodyDoesNotTakeOverAfterAmbiguousControllerRequest`) |
+| SESSION-RECON-013 | Whole-command stop timeout | An explicit `gc stop --timeout` bounds the whole stop sequence, including path resolution, supervisor unregister waits, invalid-config recovery, and loaded-city cleanup. Timeout returns nonzero and a worker that later finishes cleanup cannot emit a late success record. | `cmd/gc/cmd_stop.go`; `cmd/gc/cmd_stop_test.go` (`TestCmdStopWallClockTimeoutBoundsSupervisorManagedInvalidConfigStop`, `TestCmdStopWallClockTimeoutBoundsDirectStop`) |
### Work Release And Drain Safety
diff --git a/internal/session/lifecycle_pending_create_claim_test.go b/internal/session/lifecycle_pending_create_claim_test.go
new file mode 100644
index 0000000000..b18b16dd93
--- /dev/null
+++ b/internal/session/lifecycle_pending_create_claim_test.go
@@ -0,0 +1,108 @@
+package session
+
+import (
+ "testing"
+ "time"
+)
+
+// TestPendingCreateClaimIsLoadBearingOnObservedRuntime is the Observed:true twin
+// of the Observed:false subtests that previously concluded PendingCreateClaim
+// does not affect the projection. On the Observed:false path the
+// !input.Runtime.Observed bail returns before the PendingCreateClaim branch is
+// ever reached, so identical projections there prove nothing about the branch.
+//
+// Observed:true is the only value either production RuntimeFacts construction
+// site uses (cmd/gc/session_reconcile.go:887, cmd/gc/session_sleep.go:144), so
+// this is the path that actually runs. Here the claim IS load-bearing: it
+// selects a start-requested projection that never consults creatingStateIsStale.
+func TestPendingCreateClaimIsLoadBearingOnObservedRuntime(t *testing.T) {
+ now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
+ newInput := func(claim bool) LifecycleInput {
+ return LifecycleInput{
+ StoredState: string(StateCreating),
+ PendingCreateClaim: claim,
+ LastWokeAt: "",
+ Runtime: RuntimeFacts{Observed: true, Alive: false},
+ // Ancient create against a one-minute staleness budget: any path
+ // that reaches creatingStateIsStale must classify this as stale.
+ CreatedAt: now.Add(-24 * time.Hour),
+ StaleCreatingAfter: time.Minute,
+ Now: now,
+ }
+ }
+
+ claimed := ProjectLifecycle(newInput(true))
+ unclaimed := ProjectLifecycle(newInput(false))
+
+ if claimed.RuntimeProjection == unclaimed.RuntimeProjection {
+ t.Fatalf("PendingCreateClaim did not change the projection on the Observed:true path: both = %q", claimed.RuntimeProjection)
+ }
+ if got, want := unclaimed.RuntimeProjection, RuntimeProjectionStaleCreating; got != want {
+ t.Errorf("unclaimed RuntimeProjection = %q, want %q (ancient create must age out)", got, want)
+ }
+ if got, want := unclaimed.ReconciledState, StateAsleep; got != want {
+ t.Errorf("unclaimed ReconciledState = %q, want %q", got, want)
+ }
+ if got, want := claimed.RuntimeProjection, RuntimeProjectionStartRequested; got != want {
+ t.Errorf("claimed RuntimeProjection = %q, want %q", got, want)
+ }
+ if got, want := claimed.ReconciledState, StateStartPending; got != want {
+ t.Errorf("claimed ReconciledState = %q, want %q", got, want)
+ }
+ if !claimed.CountsAgainstCap {
+ t.Error("claimed CountsAgainstCap = false, want true (a start-requested creating bead holds a capacity slot)")
+ }
+}
+
+// TestPendingCreateClaimStartRequestedHasNoAgeBound pins the absence of an age
+// bound on the claim-gated branch: no matter how old the create is, the
+// projection keeps reporting start-requested and keeps counting against
+// capacity. Age is varied across four orders of magnitude while every other
+// fact is held fixed, so a staleness check added to that branch fails here.
+func TestPendingCreateClaimStartRequestedHasNoAgeBound(t *testing.T) {
+ now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
+ for _, age := range []time.Duration{time.Minute, time.Hour, 24 * time.Hour, 30 * 24 * time.Hour} {
+ view := ProjectLifecycle(LifecycleInput{
+ StoredState: string(StateCreating),
+ PendingCreateClaim: true,
+ LastWokeAt: "",
+ Runtime: RuntimeFacts{Observed: true, Alive: false},
+ CreatedAt: now.Add(-age),
+ StaleCreatingAfter: time.Minute,
+ Now: now,
+ })
+ if got, want := view.RuntimeProjection, RuntimeProjectionStartRequested; got != want {
+ t.Errorf("age %s: RuntimeProjection = %q, want %q", age, got, want)
+ }
+ if !view.CountsAgainstCap {
+ t.Errorf("age %s: CountsAgainstCap = false, want true", age)
+ }
+ }
+}
+
+// TestStartPendingProjectionHasNoAgeBound covers the state the claim-gated
+// branch heals a creating bead INTO. CreateOptions{BeadOnly:true} mints session
+// intents directly in start-pending with pending_create_claim=true and no
+// last_woke_at, so this is also the shape of every fresh never-started create.
+// BaseStateStartPending returns start-requested with no staleness input at all.
+func TestStartPendingProjectionHasNoAgeBound(t *testing.T) {
+ now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
+ view := ProjectLifecycle(LifecycleInput{
+ StoredState: string(StateStartPending),
+ PendingCreateClaim: true,
+ LastWokeAt: "",
+ Runtime: RuntimeFacts{Observed: true, Alive: false},
+ CreatedAt: now.Add(-30 * 24 * time.Hour),
+ StaleCreatingAfter: time.Minute,
+ Now: now,
+ })
+ if got, want := view.RuntimeProjection, RuntimeProjectionStartRequested; got != want {
+ t.Errorf("RuntimeProjection = %q, want %q", got, want)
+ }
+ if got, want := view.ReconciledState, StateStartPending; got != want {
+ t.Errorf("ReconciledState = %q, want %q", got, want)
+ }
+ if !view.CountsAgainstCap {
+ t.Error("CountsAgainstCap = false, want true")
+ }
+}
diff --git a/internal/session/lifecycle_timers.go b/internal/session/lifecycle_timers.go
index c56491d7ab..d57a346c0c 100644
--- a/internal/session/lifecycle_timers.go
+++ b/internal/session/lifecycle_timers.go
@@ -68,7 +68,7 @@ type TimerFacts struct {
// Pending is the pending-interaction fact, gathered on demand.
Pending PendingFact
// AssignedWork is the open-assigned-work fact, gathered on demand.
- // Only the max-session-age ladder consults it.
+ // Both the max-session-age and idle-timeout ladders consult it.
AssignedWork AssignedWorkFact
}
@@ -125,10 +125,13 @@ func DecideMaxSessionAge(f TimerFacts) TimerDecision {
}
// DecideIdleTimeout evaluates the idle-timeout ladder: blocker, then pending
-// interaction, then stop. Idle stops never consult assigned work. A pending
-// interaction cancels any pending drain and keeps the session out of this
-// tick's wake pass — asymmetries with max-session-age that are part of the
-// existing reconciler contract.
+// interaction, then assigned work, then stop. A pending interaction cancels
+// any pending drain and keeps the session out of this tick's wake pass — an
+// asymmetry with max-session-age that is part of the existing reconciler
+// contract. Assigned work defers the stop, mirroring DecideMaxSessionAge:
+// without this rung, ComputeAwakeSet's assigned-work exemption re-wakes the
+// session within seconds of the kill, producing an unbounded idle-kill/wake
+// treadmill (ga-3ox7rk).
func DecideIdleTimeout(f TimerFacts) TimerDecision {
if !f.Triggered {
return TimerDecision{Action: TimerActionNone}
@@ -145,6 +148,12 @@ func DecideIdleTimeout(f TimerFacts) TimerDecision {
dec.SkipWakePass = true
return dec
}
+ switch f.AssignedWork {
+ case AssignedWorkUnknown:
+ return TimerDecision{Action: TimerActionGatherAssignedWork}
+ case AssignedWorkHas:
+ return deferDecision("assigned_work", "deferred_busy")
+ }
return TimerDecision{
Action: TimerActionStop,
TraceReason: "idle_timeout",
@@ -156,3 +165,25 @@ func DecideIdleTimeout(f TimerFacts) TimerDecision {
func deferDecision(reason, outcome string) TimerDecision {
return TimerDecision{Action: TimerActionDefer, TraceReason: reason, TraceOutcome: outcome}
}
+
+// DecideAssignedWorkExhausted is the forced-stop decision for a session that
+// has deferred the idle-timeout stop on the same assigned-work bead more
+// times than the reconciler's configured consecutive-defer limit. The
+// reconciler owns the anchor bead identity, the consecutive-defer count, and
+// the limit; this function only supplies the decision vocabulary once the
+// caller has decided to override DecideIdleTimeout's AssignedWorkHas defer.
+// The distinct TraceReason/SleepReason (as opposed to plain "idle_timeout")
+// make the override traceable back to the backstop rather than an ordinary
+// idle stop. SleepReasonAssignedWorkExhausted is deliberately absent from
+// IsDeliberateSleepReason and shouldResetContinuation, mirroring
+// SleepReasonMaxSessionAge: a session that keeps hitting this backstop across
+// respawns should accrue churn and reset continuation, the same
+// defense-in-depth treatment as a forced max-session-age restart.
+func DecideAssignedWorkExhausted() TimerDecision {
+ return TimerDecision{
+ Action: TimerActionStop,
+ TraceReason: "assigned_work_exhausted",
+ TraceOutcome: "stop_defer_exhausted",
+ SleepReason: string(SleepReasonAssignedWorkExhausted),
+ }
+}
diff --git a/internal/session/lifecycle_timers_test.go b/internal/session/lifecycle_timers_test.go
index d17c70220c..d22ab63316 100644
--- a/internal/session/lifecycle_timers_test.go
+++ b/internal/session/lifecycle_timers_test.go
@@ -133,8 +133,20 @@ func TestDecideIdleTimeoutLadder(t *testing.T) {
action: TimerActionGatherPending,
},
{
- name: "idle session stops",
- facts: TimerFacts{Triggered: true, Pending: PendingNo},
+ name: "unknown assigned work must be gathered",
+ facts: TimerFacts{Triggered: true, Pending: PendingNo},
+ action: TimerActionGatherAssignedWork,
+ },
+ {
+ name: "assigned work defers the stop",
+ facts: TimerFacts{Triggered: true, Pending: PendingNo, AssignedWork: AssignedWorkHas},
+ action: TimerActionDefer,
+ reason: "assigned_work",
+ outcome: "deferred_busy",
+ },
+ {
+ name: "free idle session stops",
+ facts: TimerFacts{Triggered: true, Pending: PendingNo, AssignedWork: AssignedWorkNone},
action: TimerActionStop,
reason: "idle_timeout",
outcome: "stop",
@@ -187,16 +199,51 @@ func TestDecideMaxSessionAgePendingKeepsWakePass(t *testing.T) {
}
}
-// Idle-timeout never consults assigned work; an unknown work fact must not
-// trigger a gather action or change the stop decision.
-func TestDecideIdleTimeoutIgnoresAssignedWork(t *testing.T) {
- dec := DecideIdleTimeout(TimerFacts{Triggered: true, Pending: PendingNo, AssignedWork: AssignedWorkUnknown})
- if dec.Action != TimerActionStop {
- t.Fatalf("action = %v, want stop", dec.Action)
- }
+func TestDecideIdleTimeoutStopSleepReason(t *testing.T) {
+ dec := DecideIdleTimeout(TimerFacts{Triggered: true, Pending: PendingNo, AssignedWork: AssignedWorkNone})
if dec.SleepReason != "idle-timeout" {
t.Fatalf("sleep reason = %q, want %q", dec.SleepReason, "idle-timeout")
}
+ if dec.CancelDrain || dec.SkipWakePass {
+ t.Fatalf("idle stop must not request drain cancel or wake-pass skip: %+v", dec)
+ }
+}
+
+// Assigned work defers the idle-timeout stop the same way it defers
+// max-session-age, so ComputeAwakeSet's assigned-work exemption and the
+// idle-kill ladder agree instead of fighting (ga-3ox7rk).
+func TestDecideIdleTimeoutDefersOnAssignedWork(t *testing.T) {
+ dec := DecideIdleTimeout(TimerFacts{Triggered: true, Pending: PendingNo, AssignedWork: AssignedWorkHas})
+ if dec.Action != TimerActionDefer {
+ t.Fatalf("action = %v, want defer", dec.Action)
+ }
+ if dec.TraceReason != "assigned_work" || dec.TraceOutcome != "deferred_busy" {
+ t.Fatalf("trace = %q/%q, want assigned_work/deferred_busy", dec.TraceReason, dec.TraceOutcome)
+ }
+ if dec.CancelDrain || dec.SkipWakePass {
+ t.Fatalf("assigned-work deferral must not cancel drain or skip wake pass: %+v", dec)
+ }
+}
+
+// DecideAssignedWorkExhausted is the caller-invoked override for a session
+// that has deferred the idle-timeout stop on the same assigned-work bead more
+// times than the reconciler's configured consecutive-defer limit. Unlike a
+// plain idle-timeout stop it carries its own trace reason and sleep reason so
+// the override is distinguishable in traces and metadata (ga-nllza6 part 2).
+func TestDecideAssignedWorkExhausted(t *testing.T) {
+ dec := DecideAssignedWorkExhausted()
+ if dec.Action != TimerActionStop {
+ t.Fatalf("action = %v, want %v", dec.Action, TimerActionStop)
+ }
+ if dec.TraceReason != "assigned_work_exhausted" || dec.TraceOutcome != "stop_defer_exhausted" {
+ t.Fatalf("trace = %q/%q, want assigned_work_exhausted/stop_defer_exhausted", dec.TraceReason, dec.TraceOutcome)
+ }
+ if dec.SleepReason != string(SleepReasonAssignedWorkExhausted) {
+ t.Fatalf("sleep reason = %q, want %q", dec.SleepReason, SleepReasonAssignedWorkExhausted)
+ }
+ if dec.CancelDrain || dec.SkipWakePass {
+ t.Fatalf("defer-exhausted stop must not request drain cancel or wake-pass skip: %+v", dec)
+ }
}
// The gather loop must terminate: once both gatherable facts are known the
diff --git a/internal/session/manager.go b/internal/session/manager.go
index 86fa2230c3..8a03432b49 100644
--- a/internal/session/manager.go
+++ b/internal/session/manager.go
@@ -793,6 +793,17 @@ func WithStaleKeyDetectionWaiter(waiter StaleKeyDetectionWaiter) ManagerOption {
}
}
+// WithClock supplies the time source the Manager stamps lifecycle timestamps
+// from (e.g. pending_create_started_at). A nil clock retains the immutable
+// production wall clock.
+func WithClock(clk clock.Clock) ManagerOption {
+ return func(m *Manager) {
+ if clk != nil {
+ m.clk = clk
+ }
+ }
+}
+
// NewManagerWithOptions creates a Manager backed by the given bead store and
// session provider, applying any capability options. It is the canonical
// constructor; the named NewManager* variants below are one-line presets.
@@ -1128,7 +1139,7 @@ func (m *Manager) createBeadOnly(spec CreateOptions) (Info, error) {
meta["session_key"] = sessionKey
}
meta["pending_create_claim"] = "true"
- meta["pending_create_started_at"] = pendingCreateStartedAt(time.Now().UTC())
+ meta["pending_create_started_at"] = pendingCreateStartedAt(m.now().UTC())
if explicitName != "" {
meta["session_name"] = explicitName
meta["session_name_explicit"] = "true"
diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go
index 40f8494735..3b19da3c81 100644
--- a/internal/session/manager_test.go
+++ b/internal/session/manager_test.go
@@ -1185,6 +1185,38 @@ func TestCreateSessionBeadOnly(t *testing.T) {
}
}
+// TestCreateSessionBeadOnlyStampsPendingCreateStartedAtFromManagerClock pins
+// that pending_create_started_at is read from the Manager's injected clock,
+// not the real wall clock. The never-started pending-create lease
+// (cmd/gc/session_reconciler.go pendingCreateNeverStartedLeaseExpiredInfo)
+// anchors on this timestamp and compares it against clock.Fake in reconciler
+// tests; if the stamp comes from real time instead, the anchor and the
+// comparison live on different timelines and the lease can never expire in
+// those tests, silently disabling the rollback safety net.
+func TestCreateSessionBeadOnlyStampsPendingCreateStartedAtFromManagerClock(t *testing.T) {
+ store := beads.NewMemStore()
+ sp := runtime.NewFake()
+ mgr := NewManagerWithOptions(store, sp)
+ fakeNow := time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC)
+ mgr.clk = &clock.Fake{Time: fakeNow}
+
+ info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}})
+ if err != nil {
+ t.Fatalf("CreateSessionBeadOnly: %v", err)
+ }
+ b, err := store.Get(info.ID)
+ if err != nil {
+ t.Fatalf("store.Get: %v", err)
+ }
+ got, err := time.Parse(time.RFC3339, b.Metadata["pending_create_started_at"])
+ if err != nil {
+ t.Fatalf("pending_create_started_at = %q, not RFC3339: %v", b.Metadata["pending_create_started_at"], err)
+ }
+ if !got.Equal(fakeNow) {
+ t.Errorf("pending_create_started_at = %v, want %v (manager clock, not real wall clock)", got, fakeNow)
+ }
+}
+
func TestGetSurfacesAgentNameMetadata(t *testing.T) {
store := beads.NewMemStore()
sp := runtime.NewFake()
diff --git a/internal/session/sleep_reason.go b/internal/session/sleep_reason.go
index b8589be1e2..82e056d2d5 100644
--- a/internal/session/sleep_reason.go
+++ b/internal/session/sleep_reason.go
@@ -34,6 +34,7 @@ const (
SleepReasonQuarantine SleepReason = "quarantine"
SleepReasonContextChurn SleepReason = "context-churn"
SleepReasonMaxSessionAge SleepReason = "max-session-age"
+ SleepReasonAssignedWorkExhausted SleepReason = "assigned-work-exhausted"
)
// IsDeliberateSleepReason reports whether a sleep_reason records an
diff --git a/internal/session/submit_test.go b/internal/session/submit_test.go
index 0da0aa9fcf..b8230581d3 100644
--- a/internal/session/submit_test.go
+++ b/internal/session/submit_test.go
@@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"path/filepath"
- goruntime "runtime"
"strings"
"testing"
"time"
@@ -505,9 +504,6 @@ func TestEnsureSessionSubmitPollerRejectsGoTestExecutable(t *testing.T) {
}
func TestExistingSessionSubmitPollerPIDRejectsUnrelatedLivePID(t *testing.T) {
- if goruntime.GOOS != "linux" {
- t.Skip("poller ownership check uses /proc on linux")
- }
cityPath := t.TempDir()
pidPath := sessionSubmitPollerPIDPath(cityPath, "s-test", "session-id")
if err := os.MkdirAll(filepath.Dir(pidPath), 0o755); err != nil {
@@ -527,9 +523,6 @@ func TestExistingSessionSubmitPollerPIDRejectsUnrelatedLivePID(t *testing.T) {
}
func TestExistingSessionSubmitPollerPIDAcceptsMatchingCitySession(t *testing.T) {
- if goruntime.GOOS != "linux" {
- t.Skip("poller ownership check uses /proc on linux")
- }
cityPath := t.TempDir()
sessionName := "s-test"
pidPath := sessionSubmitPollerPIDPath(cityPath, sessionName, "session-id")
@@ -551,9 +544,6 @@ func TestExistingSessionSubmitPollerPIDAcceptsMatchingCitySession(t *testing.T)
}
func TestExistingSessionSubmitPollerPIDRejectsDifferentCitySameSession(t *testing.T) {
- if goruntime.GOOS != "linux" {
- t.Skip("poller ownership check uses /proc on linux")
- }
cityPath := t.TempDir()
otherCityPath := t.TempDir()
sessionName := "s-test"
@@ -576,9 +566,6 @@ func TestExistingSessionSubmitPollerPIDRejectsDifferentCitySameSession(t *testin
}
func TestExistingSessionSubmitPollerPIDRejectsDifferentTargetSameCitySession(t *testing.T) {
- if goruntime.GOOS != "linux" {
- t.Skip("poller ownership check uses /proc on linux")
- }
cityPath := t.TempDir()
sessionName := "s-test"
pidPath := sessionSubmitPollerPIDPath(cityPath, sessionName, "session-id")
diff --git a/internal/sessionlog/context.go b/internal/sessionlog/context.go
index 06e56c8cee..0a16d6342c 100644
--- a/internal/sessionlog/context.go
+++ b/internal/sessionlog/context.go
@@ -2,43 +2,10 @@
// lightweight metadata extraction (model, context usage).
package sessionlog
-import "strings"
+import "github.com/gastownhall/gascity/internal/modelwindow"
-// modelFamilyWindows maps model family keywords to their context window sizes.
-var modelFamilyWindows = map[string]int{
- "opus": 200_000,
- "sonnet": 200_000,
- "haiku": 200_000,
- "gemini": 1_000_000,
- "gpt-5": 258_000,
- "codex": 258_000,
- "gpt-4": 128_000,
- "gpt-4o": 128_000,
-}
-
-// millionTokenWindow is the context window for 1M-token model variants.
-const millionTokenWindow = 1_000_000
-
-// claudeFamilies are the Claude model families whose context window scales to
-// 1M when the model ID carries the "[1m]" suffix (e.g. "claude-opus-4-8[1m]").
-// Without the suffix they use the 200K default in modelFamilyWindows.
-var claudeFamilies = map[string]bool{"opus": true, "sonnet": true, "haiku": true}
-
-// ModelContextWindow returns the context window size for a model ID.
-// It parses the model ID to extract the family name and looks it up.
-// Claude families carrying the "[1m]" suffix resolve to the 1M window so
-// context utilization does not saturate against the 200K default.
-// Returns 0 if the model family is unknown.
+// ModelContextWindow returns the context-window size for a model ID; it
+// delegates to modelwindow.Window.
func ModelContextWindow(model string) int {
- lower := strings.ToLower(model)
- // Try longer matches first to avoid "gpt-4" matching before "gpt-4o".
- for _, family := range []string{"gpt-4o", "gpt-5", "gpt-4", "opus", "sonnet", "haiku", "gemini", "codex"} {
- if strings.Contains(lower, family) {
- if claudeFamilies[family] && strings.Contains(lower, "[1m]") {
- return millionTokenWindow
- }
- return modelFamilyWindows[family]
- }
- }
- return 0
+ return modelwindow.Window(model)
}
diff --git a/internal/sessionlog/context_opus5_ra_jbbv0_test.go b/internal/sessionlog/context_opus5_ra_jbbv0_test.go
new file mode 100644
index 0000000000..cca795c64f
--- /dev/null
+++ b/internal/sessionlog/context_opus5_ra_jbbv0_test.go
@@ -0,0 +1,71 @@
+package sessionlog
+
+import (
+ "testing"
+
+ "github.com/gastownhall/gascity/internal/modelwindow"
+)
+
+// TestOpus5IsNativelyOneMillion pins the regression behind ra-jbbv0 at the
+// sessionlog boundary.
+//
+// Opus 5 ships a 1M context window natively — there is no 200K Opus 5 variant,
+// and it is the CLI's default Opus. ModelContextWindow originally matched only
+// the bare family word "opus" and returned the 200K default for it, so an agent
+// actually being served Opus 5 had its utilization gauge computed against a
+// denominator 5x too small. Measured consequence in the incident: a session
+// peaking at 771,916 tokens reported 386% and the ADVISORY/URGENT steer
+// saturated, losing all ability to discriminate near the real ceiling.
+//
+// The window table itself now lives in internal/modelwindow — the single source
+// of truth shared with the CLI context-pressure injector — and it carries the
+// "opus-5" marker. This test remains as the guard on the sessionlog delegation:
+// it asserts the projection callers actually reach still resolves Opus 5 to 1M,
+// so a future change to ModelContextWindow cannot reintroduce the 200K
+// denominator without going red here.
+func TestOpus5IsNativelyOneMillion(t *testing.T) {
+ for _, id := range []string{
+ "claude-opus-5",
+ "opus-5",
+ "claude-opus-5[1m]", // suffix is redundant for Opus 5, must not regress
+ } {
+ if got := ModelContextWindow(id); got != modelwindow.Million {
+ t.Errorf("ModelContextWindow(%q) = %d, want %d (Opus 5 is natively 1M)", id, got, modelwindow.Million)
+ }
+ }
+}
+
+// TestPreExistingWindowsUnchanged guards the blast radius of the resolution
+// above: Opus 5 must not capture any model that is not Opus 5, and every other
+// family/suffix resolution must reach callers intact.
+//
+// The modern Claude variants below resolve to 1M without the "[1m]" suffix —
+// that is their plain default, and the provider echoes the model ID back
+// without the launch flag, so a session log only ever carries the bare form.
+// Older variants (Opus 4.5 and earlier, Haiku) stay at the conservative 200K
+// default, which is also what pins the "opus-5" marker against swallowing
+// "opus-4-5" by substring.
+func TestPreExistingWindowsUnchanged(t *testing.T) {
+ cases := map[string]int{
+ "claude-opus-4-8": modelwindow.Million,
+ "claude-opus-4-7": modelwindow.Million,
+ "claude-opus-4-8[1m]": modelwindow.Million,
+ "claude-sonnet-5": modelwindow.Million,
+ "claude-sonnet-4-6": modelwindow.Million,
+ "claude-opus-4-5-20251101": modelwindow.Default,
+ "claude-haiku-4-5-20251001": modelwindow.Default,
+ "claude-haiku-4-5-20251001[1m]": modelwindow.Million,
+ "gemini-2.5-pro": 1_000_000,
+ "gpt-4o-2024-08-06": 128_000,
+ "gpt-5-20260101": 258_000,
+ "codex-mini-latest": 258_000,
+ "gpt-4-turbo": 128_000,
+ "unknown-model-xyz": 0,
+ "": 0,
+ }
+ for id, want := range cases {
+ if got := ModelContextWindow(id); got != want {
+ t.Errorf("ModelContextWindow(%q) = %d, want %d", id, got, want)
+ }
+ }
+}
diff --git a/internal/sessionlog/context_test.go b/internal/sessionlog/context_test.go
index e1ba3c7e12..cf99eb4087 100644
--- a/internal/sessionlog/context_test.go
+++ b/internal/sessionlog/context_test.go
@@ -8,9 +8,22 @@ func TestModelContextWindow(t *testing.T) {
want int
}{
{"claude-opus-4-5-20251101", 200_000},
+ {"claude-opus-4-1-20250805", 200_000}, // opus-5 marker must not swallow this
{"claude-sonnet-4-5-20251101", 200_000},
{"claude-haiku-4-5-20251001", 200_000},
- // 1M-window Claude variants carry a "[1m]" suffix on the model ID.
+ // Modern Claude variants have a 1M window WITHOUT the "[1m]" suffix: the
+ // provider echoes the model ID back without the launch flag, so a bare ID
+ // read out of a session log must still resolve to 1M.
+ {"claude-opus-4-8", 1_000_000},
+ {"claude-opus-4-7", 1_000_000},
+ {"claude-opus-4-6", 1_000_000},
+ {"claude-opus-5", 1_000_000},
+ {"claude-sonnet-4-6", 1_000_000},
+ {"claude-sonnet-5", 1_000_000},
+ {"claude-opus-4-8-20260101", 1_000_000}, // dated variant still matches
+ {"claude-fable-5", 1_000_000},
+ {"claude-mythos-1", 1_000_000},
+ // The explicit "[1m]" suffix forces 1M for any Claude family.
{"claude-opus-4-8[1m]", 1_000_000},
{"sonnet[1m]", 1_000_000},
{"claude-haiku-4-5-20251001[1m]", 1_000_000},
diff --git a/internal/sling/sling.go b/internal/sling/sling.go
index 7e83905b43..79a1d3a124 100644
--- a/internal/sling/sling.go
+++ b/internal/sling/sling.go
@@ -16,6 +16,8 @@ import (
"github.com/gastownhall/gascity/internal/beadmeta"
"github.com/gastownhall/gascity/internal/beads"
"github.com/gastownhall/gascity/internal/config"
+ "github.com/gastownhall/gascity/internal/events"
+ "github.com/gastownhall/gascity/internal/executionevent"
"github.com/gastownhall/gascity/internal/formula"
"github.com/gastownhall/gascity/internal/fsys"
"github.com/gastownhall/gascity/internal/graphroute"
@@ -123,7 +125,10 @@ type SlingDeps struct {
// store). When nil, graph beads collapse onto Store — the single-store
// default — so a single-store caller behaves exactly as before the seam.
GraphStore beads.Store
- StoreRef string
+ // Events records best-effort current execution facts after graph workflow
+ // materialization. Nil leaves sling event-silent.
+ Events events.Recorder
+ StoreRef string
// ValidationQuerier overrides Store for existence checks when a caller has
// already resolved the bead through a narrower view.
ValidationQuerier BeadQuerier
@@ -1399,9 +1404,18 @@ func materializeCompiledSlingFormula(ctx context.Context, recipe *formula.Recipe
return nil, err
}
SlingTracef("instantiate done formula=%s dur=%s root=%s created=%d graph=%t", formulaName, time.Since(instantiateStart), result.RootID, result.Created, result.GraphWorkflow)
+ if graphWorkflow {
+ emitCurrentExecutionFacts(deps, graphStore, result.RootID, a.QualifiedName(), formulaName)
+ }
return result, nil
}
+func emitCurrentExecutionFacts(deps SlingDeps, graphStore beads.Store, rootID, actor, formulaName string) {
+ if err := executionevent.EmitCurrent(deps.Events, beads.GraphStore{Store: graphStore}, beads.WorkStore{Store: deps.Store}, rootID, actor); err != nil {
+ depsTracef(deps, "execution snapshot projection failed formula=%s root=%s err=%v", formulaName, rootID, err)
+ }
+}
+
func closeReplacedGraphV2Root(store beads.Store, rootID string) ([]sourceworkflow.WorkflowBeadSnapshot, error) {
root, err := store.Get(rootID)
if err != nil {
diff --git a/internal/sling/sling_core.go b/internal/sling/sling_core.go
index 7c98edc663..0e0cb2c127 100644
--- a/internal/sling/sling_core.go
+++ b/internal/sling/sling_core.go
@@ -174,7 +174,7 @@ func resolveIdempotentShortCircuit(opts SlingOpts, a config.Agent, deps SlingDep
NoConvoy: opts.NoConvoy,
})
if check.Idempotent {
- needsAttach, probeErr := onFormulaNeedsAttachment(opts, querier, deps)
+ decision, probeErr := onFormulaNeedsAttachment(opts, querier, deps)
switch {
case probeErr != nil:
// The attachment probe failed, so we cannot prove the routed bead
@@ -184,11 +184,21 @@ func resolveIdempotentShortCircuit(opts SlingOpts, a config.Agent, deps SlingDep
result.BeadWarnings = append(result.BeadWarnings, fmt.Sprintf(
"could not verify molecule attachment for %s; treating --on as an idempotent no-op: %v",
opts.BeadOrFormula, probeErr))
- case needsAttach:
+ case decision.NeedsAttach:
// The bead is routed to the target but carries no molecule — an
// earlier plain sling routed it raw. Do not treat --on as an
// idempotent no-op; fall through so the formula attaches.
check.Idempotent = false
+ case decision.SkippedForClaim:
+ // Another worker already claimed this bead and no molecule is
+ // attached. Idempotency is preserved deliberately (do not re-attach
+ // onto in-progress work), but say so explicitly: without this
+ // warning the CLI prints only the generic "already routed" message,
+ // giving no signal that the requested --on formula was never
+ // attached or that --force would override the skip.
+ result.BeadWarnings = append(result.BeadWarnings, fmt.Sprintf(
+ "bead %s is claimed by %s with no molecule attached; --on %s was skipped to avoid re-attaching onto in-progress work — rerun with --force to attach it anyway",
+ opts.BeadOrFormula, decision.Assignee, opts.OnFormula))
}
}
if !check.Idempotent {
@@ -253,6 +263,22 @@ func shouldCheckBeadState(opts SlingOpts) bool {
return !opts.IsFormula && !opts.Force && (!opts.DryRun || !opts.InlineText)
}
+// attachmentDecision is the result of onFormulaNeedsAttachment: whether an
+// --on formula attach should proceed on an otherwise-idempotent routed bead,
+// and, when it should not, why -- so the caller can distinguish "nothing to
+// do" (a molecule is already attached) from "skipped because another worker
+// owns this bead" (SkippedForClaim), which needs its own warning rather than
+// silently folding into the generic idempotent no-op.
+type attachmentDecision struct {
+ NeedsAttach bool
+ // SkippedForClaim is true when the bead has no molecule but is already
+ // claimed (Assignee set), so the attach was intentionally skipped rather
+ // than performed. Only meaningful when NeedsAttach is false.
+ SkippedForClaim bool
+ // Assignee is the claiming identity when SkippedForClaim is true.
+ Assignee string
+}
+
// onFormulaNeedsAttachment reports whether this is an --on sling whose target
// bead the caller has already determined reads Idempotent (gc.routed_to ==
// target, or pool-labeled) but that has no attached molecule yet. The
@@ -264,29 +290,35 @@ func shouldCheckBeadState(opts SlingOpts) bool {
// molecule; a stale one is burned).
//
// The returned error is non-nil only when the molecule-attachment probe could
-// not complete. In that case the result is (false, err): the caller cannot
-// prove the bead is unmoleculed, so it must preserve the fail-closed idempotent
-// state rather than clear it and risk minting a duplicate attachment.
-func onFormulaNeedsAttachment(opts SlingOpts, querier BeadQuerier, deps SlingDeps) (bool, error) {
+// not complete. In that case the result is (attachmentDecision{}, err): the
+// caller cannot prove the bead is unmoleculed, so it must preserve the
+// fail-closed idempotent state rather than clear it and risk minting a
+// duplicate attachment.
+func onFormulaNeedsAttachment(opts SlingOpts, querier BeadQuerier, deps SlingDeps) (attachmentDecision, error) {
if opts.OnFormula == "" {
- return false, nil
+ return attachmentDecision{}, nil
}
hasMolecule, err := HasMoleculeChildren(querier, opts.BeadOrFormula, deps.Store)
if err != nil {
- return false, err
+ return attachmentDecision{}, err
}
if hasMolecule {
- return false, nil
+ return attachmentDecision{}, nil
}
// No molecule attached. Only override idempotency for an UNCLAIMED bead — the
// routed-raw footgun (gc.routed_to set, no assignee, no molecule). If a worker
// has already claimed it (assignee set), leave it idempotent rather than
- // re-attaching a formula onto work in progress.
+ // re-attaching a formula onto work in progress -- but report the claim so the
+ // caller can warn that the attach was skipped, distinctly from "already done".
bead, ok := BeadFromGetters(opts.BeadOrFormula, querier, deps.Store)
if !ok {
- return false, nil
+ return attachmentDecision{}, nil
+ }
+ assignee := strings.TrimSpace(bead.Assignee)
+ if assignee == "" {
+ return attachmentDecision{NeedsAttach: true}, nil
}
- return strings.TrimSpace(bead.Assignee) == "", nil
+ return attachmentDecision{SkippedForClaim: true, Assignee: assignee}, nil
}
func shouldValidateBuiltInRouteStoreReachable(opts SlingOpts, deps SlingDeps) bool {
@@ -459,9 +491,10 @@ func attachFormulaToBead(opts SlingOpts, deps SlingDeps, querier BeadQuerier, be
Title: opts.Title,
Vars: formulaVars,
}); err != nil {
+ graphv2.CloseSyntheticInputConvoy(deps.Store, graphInv.InputConvoy, beadID)
return result, fmt.Errorf("instantiating %s %q on %s: %w", errLabel, formulaName, beadID, err)
}
- return withGraphV2SourceWorkflowLock(context.Background(), deps, beadID, func() (SlingResult, error) {
+ lockedResult, lockedErr := withGraphV2SourceWorkflowLock(context.Background(), deps, beadID, func() (SlingResult, error) {
if err := CheckNoMoleculeChildrenAllowLiveWorkflow(querier, beadID, deps.Store, &result); err != nil {
return result, fmt.Errorf("%w", err)
}
@@ -489,6 +522,15 @@ func attachFormulaToBead(opts SlingOpts, deps SlingDeps, querier BeadQuerier, be
}
return wfResult, wfErr
})
+ if lockedErr != nil {
+ // The pour failed after minting its synthetic input convoy
+ // (children-conflict, snapshot, instantiate, or start failure —
+ // the started-workflow path returns nil error). Close the pour's
+ // own artifact so repeated failures do not accumulate open
+ // claim-attracting convoys.
+ graphv2.CloseSyntheticInputConvoy(deps.Store, graphInv.InputConvoy, beadID)
+ }
+ return lockedResult, lockedErr
}
if err := validateSlingFormulaRuntimeVars(context.Background(), formulaName, searchPaths, molecule.Options{
Title: opts.Title,
diff --git a/internal/sling/sling_on_idempotency_test.go b/internal/sling/sling_on_idempotency_test.go
index 40e5a978bf..41f8f60ee5 100644
--- a/internal/sling/sling_on_idempotency_test.go
+++ b/internal/sling/sling_on_idempotency_test.go
@@ -2,6 +2,7 @@ package sling
import (
"errors"
+ "strings"
"testing"
"github.com/gastownhall/gascity/internal/beads"
@@ -41,16 +42,18 @@ func TestOnFormulaNeedsAttachment(t *testing.T) {
deps := SlingDeps{Store: store}
// A non---on sling never overrides idempotency.
- if need, err := onFormulaNeedsAttachment(SlingOpts{BeadOrFormula: routedRaw.ID}, store, deps); need || err != nil {
- t.Errorf("plain sling: onFormulaNeedsAttachment = (%v, %v), want (false, nil)", need, err)
+ if dec, err := onFormulaNeedsAttachment(SlingOpts{BeadOrFormula: routedRaw.ID}, store, deps); dec.NeedsAttach || err != nil {
+ t.Errorf("plain sling: onFormulaNeedsAttachment = (%+v, %v), want NeedsAttach=false, nil", dec, err)
}
// --on on a routed-raw (unclaimed, no-molecule) bead must attach (the footgun).
- if need, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: routedRaw.ID}, store, deps); !need || err != nil {
- t.Errorf("routed-raw --on: onFormulaNeedsAttachment = (%v, %v), want (true, nil) (no molecule => must attach)", need, err)
+ if dec, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: routedRaw.ID}, store, deps); !dec.NeedsAttach || err != nil {
+ t.Errorf("routed-raw --on: onFormulaNeedsAttachment = (%+v, %v), want NeedsAttach=true, nil (no molecule => must attach)", dec, err)
}
// A CLAIMED bead (assignee set) with no molecule stays idempotent — do not
- // re-attach onto a worker's in-progress bead.
+ // re-attach onto a worker's in-progress bead. The decision still reports the
+ // claim so the caller can warn instead of silently no-op'ing the requested
+ // formula attach.
claimed, err := store.Create(beads.Bead{
Type: "task",
Status: "open",
@@ -60,8 +63,12 @@ func TestOnFormulaNeedsAttachment(t *testing.T) {
if err != nil {
t.Fatalf("create claimed: %v", err)
}
- if need, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: claimed.ID}, store, deps); need || err != nil {
- t.Errorf("claimed --on: onFormulaNeedsAttachment = (%v, %v), want (false, nil) (worker owns it, stay idempotent)", need, err)
+ dec, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: claimed.ID}, store, deps)
+ if dec.NeedsAttach || err != nil {
+ t.Errorf("claimed --on: onFormulaNeedsAttachment = (%+v, %v), want NeedsAttach=false, nil (worker owns it, stay idempotent)", dec, err)
+ }
+ if !dec.SkippedForClaim || dec.Assignee != "worker" {
+ t.Errorf("claimed --on: onFormulaNeedsAttachment = %+v, want SkippedForClaim=true, Assignee=%q", dec, "worker")
}
}
@@ -91,8 +98,8 @@ func TestRoutedRawBeadReadsIdempotentWhichOnFormulaMustOverride(t *testing.T) {
t.Fatalf("routed-raw bead: expected Idempotent=true (the footgun), got %+v", res)
}
// ...and the --on override fires because there is no molecule.
- if need, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: bead.ID}, store, SlingDeps{Store: store}); !need || err != nil {
- t.Fatalf("--on override should fire for a routed-raw bead with no molecule: got (%v, %v)", need, err)
+ if dec, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: bead.ID}, store, SlingDeps{Store: store}); !dec.NeedsAttach || err != nil {
+ t.Fatalf("--on override should fire for a routed-raw bead with no molecule: got (%+v, %v)", dec, err)
}
}
@@ -107,8 +114,12 @@ func TestOnFormulaNeedsAttachmentMoleculePresentStaysIdempotent(t *testing.T) {
{ID: "MOL-1", Type: "molecule", Status: "open", ParentID: "BL-1"},
}, nil)
deps := SlingDeps{Store: store}
- if need, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: "BL-1"}, store, deps); need || err != nil {
- t.Errorf("molecule-present --on: onFormulaNeedsAttachment = (%v, %v), want (false, nil) (has molecule => stay idempotent)", need, err)
+ dec, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: "BL-1"}, store, deps)
+ if dec.NeedsAttach || err != nil {
+ t.Errorf("molecule-present --on: onFormulaNeedsAttachment = (%+v, %v), want NeedsAttach=false, nil (has molecule => stay idempotent)", dec, err)
+ }
+ if dec.SkippedForClaim {
+ t.Errorf("molecule-present --on: onFormulaNeedsAttachment = %+v, want SkippedForClaim=false (molecule already present, not a claim skip)", dec)
}
}
@@ -126,11 +137,51 @@ func TestOnFormulaNeedsAttachmentProbeErrorStaysIdempotent(t *testing.T) {
store := listErrStore{Store: mem, err: probeErr}
deps := SlingDeps{Store: store}
- need, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: "BL-1"}, store, deps)
- if need {
- t.Error("probe error: onFormulaNeedsAttachment = true, want false (cannot prove no molecule => fail closed)")
+ dec, err := onFormulaNeedsAttachment(SlingOpts{OnFormula: "code-review", BeadOrFormula: "BL-1"}, store, deps)
+ if dec.NeedsAttach {
+ t.Error("probe error: onFormulaNeedsAttachment NeedsAttach = true, want false (cannot prove no molecule => fail closed)")
}
if !errors.Is(err, probeErr) {
t.Errorf("probe error: onFormulaNeedsAttachment err = %v, want %v surfaced", err, probeErr)
}
}
+
+// When resolveIdempotentShortCircuit stays idempotent specifically because the
+// bead is claimed with no molecule attached, it must say so in a bead warning
+// -- distinct from the generic "already routed" message -- rather than
+// silently returning exit 0 with no indication that the requested --on
+// formula was never attached. This pins ga-juszt2: the prior behavior gave no
+// signal that --force was required to actually attach the formula.
+func TestResolveIdempotentShortCircuitWarnsWhenOnFormulaSkippedForClaim(t *testing.T) {
+ store := beads.NewMemStoreFrom(0, []beads.Bead{
+ {
+ ID: "BL-1",
+ Type: "task",
+ Status: "open",
+ Assignee: "worker",
+ Metadata: map[string]string{"gc.routed_to": "worker"},
+ },
+ }, nil)
+ deps := SlingDeps{Store: store}
+ // NoConvoy: true bypasses the separate convoy-tracking recovery check (a
+ // parentless routed bead would otherwise read as needing finalize to
+ // recreate a missing auto-convoy) so this test isolates the claim-skip
+ // warning path under test rather than that unrelated mechanism.
+ opts := SlingOpts{OnFormula: "mol-tdd-build", BeadOrFormula: "BL-1", Target: config.Agent{Name: "worker"}, NoConvoy: true}
+
+ var result SlingResult
+ shortCircuited := resolveIdempotentShortCircuit(opts, opts.Target, deps, store, &result)
+
+ if !shortCircuited || !result.Idempotent {
+ t.Fatalf("claimed bead, no molecule, --on: expected idempotent short-circuit, got shortCircuited=%v result=%+v", shortCircuited, result)
+ }
+ if len(result.BeadWarnings) != 1 {
+ t.Fatalf("claimed bead, no molecule, --on: want exactly 1 bead warning, got %d: %+v", len(result.BeadWarnings), result.BeadWarnings)
+ }
+ warning := result.BeadWarnings[0]
+ for _, want := range []string{"BL-1", "worker", "mol-tdd-build", "--force"} {
+ if !strings.Contains(warning, want) {
+ t.Errorf("claimed bead, no molecule, --on: warning %q does not mention %q", warning, want)
+ }
+ }
+}
diff --git a/internal/sling/sling_test.go b/internal/sling/sling_test.go
index 4ee9968b87..003575a9bc 100644
--- a/internal/sling/sling_test.go
+++ b/internal/sling/sling_test.go
@@ -16,6 +16,7 @@ import (
beadsexec "github.com/gastownhall/gascity/internal/beads/exec"
"github.com/gastownhall/gascity/internal/config"
convoycore "github.com/gastownhall/gascity/internal/convoy"
+ "github.com/gastownhall/gascity/internal/events"
"github.com/gastownhall/gascity/internal/formulatest"
"github.com/gastownhall/gascity/internal/fsys"
"github.com/gastownhall/gascity/internal/molecule"
@@ -1982,6 +1983,8 @@ func TestSlingLaunchFormula(t *testing.T) {
runner := newFakeRunner()
cfg := &config.City{Workspace: config.Workspace{Name: "test"}}
deps := testDeps(cfg, runtime.NewFake(), runner.run)
+ recorder := events.NewFake()
+ deps.Events = recorder
s, err := New(deps)
if err != nil {
t.Fatal(err)
@@ -2001,6 +2004,9 @@ func TestSlingLaunchFormula(t *testing.T) {
if result.BeadID == "" {
t.Error("expected non-empty BeadID")
}
+ if len(recorder.Events) != 0 {
+ t.Fatalf("non-graph formula emitted execution facts: %#v", recorder.Events)
+ }
}
// --- Typed router tests ---
@@ -2508,6 +2514,55 @@ func TestSlingAttachGraphFormulaCreatesConvoyFirstRoot(t *testing.T) {
}
}
+func TestSlingAttachGraphFormulaEmitsCurrentExecutionFacts(t *testing.T) {
+ formulaDir := t.TempDir()
+ writeGraphV2ConvoyFormula(t, formulaDir)
+ deps := testDeps(graphV2SlingTestConfig(t, formulaDir), runtime.NewFake(), newFakeRunner().run)
+ recorder := events.NewFake()
+ deps.Events = recorder
+ source, err := deps.Store.Create(beads.Bead{Title: "work", Type: "task", Status: "open"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ s, err := New(deps)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.AttachFormula(context.Background(), "graph-work", source.ID, config.Agent{Name: "worker", MaxActiveSessions: intPtr(1)}, FormulaOpts{}); err != nil {
+ t.Fatalf("AttachFormula: %v", err)
+ }
+
+ if len(recorder.Events) != 3 {
+ t.Fatalf("execution events = %#v, want work association and two step definitions", recorder.Events)
+ }
+ if recorder.Events[0].Type != events.ExecutionWorkAssociated || recorder.Events[1].Type != events.ExecutionStepDefined || recorder.Events[2].Type != events.ExecutionStepDefined {
+ t.Fatalf("execution event types = %s, %s, %s, want association then definitions", recorder.Events[0].Type, recorder.Events[1].Type, recorder.Events[2].Type)
+ }
+}
+
+func TestInstantiateGraphFormulaPreservesMaterializationWhenProjectionFails(t *testing.T) {
+ formulaDir := t.TempDir()
+ writeGraphV2ConvoyFormula(t, formulaDir)
+ deps := testDeps(graphV2SlingTestConfig(t, formulaDir), runtime.NewFake(), newFakeRunner().run)
+ store := deps.Store
+ deps.Events = events.NewFake()
+ convoy, err := store.Create(beads.Bead{Title: "input", Type: "convoy"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, molecule.Options{Vars: map[string]string{"convoy_id": convoy.ID}}, "", "", "", config.Agent{Name: "worker"}, deps)
+ if err != nil {
+ t.Fatalf("InstantiateSlingFormula: %v", err)
+ }
+ var traces []string
+ deps.Tracer = func(format string, args ...any) { traces = append(traces, fmt.Sprintf(format, args...)) }
+ emitCurrentExecutionFacts(deps, &getErrStore{Store: store, err: fmt.Errorf("projection store unavailable")}, result.RootID, "worker", "graph-work")
+ if !slices.ContainsFunc(traces, func(trace string) bool { return strings.Contains(trace, "execution snapshot projection failed") }) {
+ t.Fatalf("traces = %#v, want projection failure", traces)
+ }
+}
+
func TestSlingAttachGraphFormulaCreatesFreshRootForBareBeadTarget(t *testing.T) {
formulaDir := t.TempDir()
writeGraphV2ConvoyFormula(t, formulaDir)
diff --git a/internal/sourceworkflow/sourceworkflow.go b/internal/sourceworkflow/sourceworkflow.go
index 06d5304e66..6a77420227 100644
--- a/internal/sourceworkflow/sourceworkflow.go
+++ b/internal/sourceworkflow/sourceworkflow.go
@@ -27,6 +27,7 @@ import (
"github.com/gastownhall/gascity/internal/beads"
"github.com/gastownhall/gascity/internal/beads/closeorder"
"github.com/gastownhall/gascity/internal/citylayout"
+ "github.com/gastownhall/gascity/internal/pathutil"
)
// ConflictError is returned when a graph workflow launch is blocked by one
@@ -350,11 +351,25 @@ func canonicalScopeRef(scopeRef string) string {
if scopeRef == "" {
return ""
}
- scopeRef = filepath.Clean(scopeRef)
- if resolved, err := filepath.EvalSymlinks(scopeRef); err == nil && strings.TrimSpace(resolved) != "" {
- return resolved
+ if isStoreScopeSentinel(scopeRef) {
+ return scopeRef
}
- return scopeRef
+ return pathutil.NormalizePathForCompare(scopeRef)
+}
+
+// isStoreScopeSentinel reports whether ref is a logical store reference such
+// as "rig:alpha" or "city:main" rather than a filesystem path.
+// LockScopeForStoreRef falls through to the literal ref when a rig name cannot
+// be resolved to a path; absolutizing that sentinel would make the derived
+// lock key and lock filename depend on the caller's working directory and
+// silently weaken mutual exclusion. A single-character scheme (a Windows drive
+// letter) is a path, not a sentinel.
+func isStoreScopeSentinel(ref string) bool {
+ i := strings.IndexByte(ref, ':')
+ if i < 2 {
+ return false
+ }
+ return !strings.ContainsAny(ref[:i], `/\`)
}
// ListWorkflowBeads returns the root and all descendant beads tagged with
@@ -776,12 +791,5 @@ func canonicalCityPath(cityPath string) (string, error) {
if cleaned == "" || cleaned == "." {
return "", fmt.Errorf("source workflow lock requires city path")
}
- abs, err := filepath.Abs(cleaned)
- if err != nil {
- return "", fmt.Errorf("canonicalize city path: %w", err)
- }
- if resolved, err := filepath.EvalSymlinks(abs); err == nil && strings.TrimSpace(resolved) != "" {
- return resolved, nil
- }
- return abs, nil
+ return pathutil.NormalizePathForCompare(cleaned), nil
}
diff --git a/internal/sourceworkflow/sourceworkflow_test.go b/internal/sourceworkflow/sourceworkflow_test.go
index 19fd5324ce..83e08c0103 100644
--- a/internal/sourceworkflow/sourceworkflow_test.go
+++ b/internal/sourceworkflow/sourceworkflow_test.go
@@ -9,6 +9,8 @@ import (
"testing"
"time"
+ "github.com/gastownhall/gascity/internal/testutil"
+
"github.com/gastownhall/gascity/internal/beads"
)
@@ -954,3 +956,98 @@ func TestSnapshotRestoreWorkflowBeadsRestoresMutableState(t *testing.T) {
t.Fatalf("child unrelated metadata = %q, want keep", got)
}
}
+
+// TestCanonicalScopeRefResolvesSymlinkedParentWithMissingLeaf pins the
+// ga-iawy13.6 canonical-path-at-ingest fix: canonicalScopeRef must resolve
+// through a symlinked parent directory even when the leaf itself does not
+// exist yet. Today it attempts EvalSymlinks only on the full path and
+// falls back to the unresolved input on failure, with no walk-up.
+func TestCanonicalScopeRefResolvesSymlinkedParentWithMissingLeaf(t *testing.T) {
+ root := t.TempDir()
+ realDir := filepath.Join(root, "real")
+ if err := os.MkdirAll(realDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ aliasDir := filepath.Join(root, "alias")
+ if err := os.Symlink(realDir, aliasDir); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+
+ missing := filepath.Join(aliasDir, "missing-leaf")
+ got := canonicalScopeRef(missing)
+
+ resolvedAlias, err := filepath.EvalSymlinks(aliasDir)
+ if err != nil {
+ t.Fatalf("EvalSymlinks(aliasDir): %v", err)
+ }
+ want := filepath.Join(resolvedAlias, "missing-leaf")
+ // testutil.AssertSamePath, not ==: the expectation comes from bare
+ // filepath.EvalSymlinks while the function under test normalizes through
+ // pathutil, which on darwin collapses the /private/var and /private/tmp
+ // host aliases back to /var and /tmp — the reverse direction. Same file,
+ // two spellings; a raw compare fails on a correct result (macOS only).
+ testutil.AssertCanonicalPathEquals(t, got, want)
+}
+
+// TestCanonicalScopeRefReturnsAbsolutePathForUnresolvableRelativeInput pins
+// that canonicalScopeRef always yields an absolute path for reliable
+// cross-process lock-key comparison, even when EvalSymlinks cannot resolve
+// anything at all. Today a relative input that cannot be resolved is
+// returned unchanged (still relative).
+func TestCanonicalScopeRefReturnsAbsolutePathForUnresolvableRelativeInput(t *testing.T) {
+ const relative = "does-not-exist-anywhere/leaf"
+ got := canonicalScopeRef(relative)
+ if !filepath.IsAbs(got) {
+ t.Errorf("canonicalScopeRef(%q) = %q, want an absolute path", relative, got)
+ }
+}
+
+// TestCanonicalCityPathResolvesSymlinkedParentWithMissingLeaf pins the
+// ga-iawy13.6 canonical-path-at-ingest fix: canonicalCityPath must resolve
+// through a symlinked parent directory even when the leaf itself does not
+// exist yet. Today it attempts EvalSymlinks only on the absolute path and
+// falls back to the unresolved abs path on failure, with no walk-up.
+func TestCanonicalCityPathResolvesSymlinkedParentWithMissingLeaf(t *testing.T) {
+ root := t.TempDir()
+ realDir := filepath.Join(root, "real")
+ if err := os.MkdirAll(realDir, 0o755); err != nil {
+ t.Fatalf("MkdirAll: %v", err)
+ }
+ aliasDir := filepath.Join(root, "alias")
+ if err := os.Symlink(realDir, aliasDir); err != nil {
+ t.Skipf("symlink unsupported: %v", err)
+ }
+
+ missing := filepath.Join(aliasDir, "missing-leaf")
+ got, err := canonicalCityPath(missing)
+ if err != nil {
+ t.Fatalf("canonicalCityPath(%q): %v", missing, err)
+ }
+
+ resolvedAlias, evalErr := filepath.EvalSymlinks(aliasDir)
+ if evalErr != nil {
+ t.Fatalf("EvalSymlinks(aliasDir): %v", evalErr)
+ }
+ want := filepath.Join(resolvedAlias, "missing-leaf")
+ // testutil.AssertSamePath, not ==: the expectation comes from bare
+ // filepath.EvalSymlinks while the function under test normalizes through
+ // pathutil, which on darwin collapses the /private/var and /private/tmp
+ // host aliases back to /var and /tmp — the reverse direction. Same file,
+ // two spellings; a raw compare fails on a correct result (macOS only).
+ testutil.AssertCanonicalPathEquals(t, got, want)
+}
+
+// TestCanonicalScopeRefKeepsStoreSentinelStableAcrossWorkingDirs pins that a
+// logical store sentinel is not absolutized. LockScopeForStoreRef returns the
+// literal "rig:" when the rig cannot be resolved to a path; if that were
+// made cwd-relative, two gc processes started from different directories would
+// derive different lock keys and lock files for the same logical scope.
+func TestCanonicalScopeRefKeepsStoreSentinelStableAcrossWorkingDirs(t *testing.T) {
+ for _, ref := range []string{"rig:alpha", "city:main"} {
+ a := func() string { t.Chdir(t.TempDir()); return canonicalScopeRef(ref) }()
+ b := func() string { t.Chdir(t.TempDir()); return canonicalScopeRef(ref) }()
+ if a != ref || b != ref {
+ t.Errorf("canonicalScopeRef(%q) = %q / %q, want %q verbatim from both dirs", ref, a, b, ref)
+ }
+ }
+}
diff --git a/internal/storehealth/storehealth.go b/internal/storehealth/storehealth.go
index 3cff5f768d..8a51b6063b 100644
--- a/internal/storehealth/storehealth.go
+++ b/internal/storehealth/storehealth.go
@@ -37,11 +37,18 @@ const MinWarnSizeBytes = 1_000_000_000 // 1 GB
// Health summarizes disk and maintenance health of the Dolt bead store.
// A pointer *Health is included in status payloads so "no data" (e.g.
// supervisor not running) is representable as nil rather than a
-// confusing zero-valued block.
+// confusing zero-valued block. The same idiom applies one level down at
+// RowsMeasured: LiveRows alone cannot distinguish a genuinely empty
+// store from a row count that failed or timed out, so a caller that
+// fabricates LiveRows=0 on measurement failure makes an unmeasured
+// store indistinguishable from a healthy one. RowsMeasured is that
+// distinction; when false, RatioMB and Warning are never computed and
+// LiveRows carries no meaning.
type Health struct {
Path string
SizeBytes int64
LiveRows int
+ RowsMeasured bool
RatioMB float64
Warning bool
ThresholdMB float64
@@ -63,16 +70,24 @@ func StorePath(cityPath string) string {
// Compute builds a Health from measured inputs. Pure function — all
// I/O is performed by the caller via WalkSize and LastMaintenance.
-func Compute(cityPath string, sizeBytes int64, retainedRows int, lastGCAt time.Time, lastGCStatus string) Health {
+//
+// rowsMeasured tells Compute whether retainedRows is a real count or a
+// caller's placeholder for "the count did not complete" (nil store,
+// scan error, timeout). Callers MUST NOT pass rowsMeasured=true with a
+// fabricated retainedRows value — doing so is exactly the defect this
+// parameter exists to prevent: a failed measurement rendering
+// byte-identically to a healthy, genuinely-empty store.
+func Compute(cityPath string, sizeBytes int64, retainedRows int, rowsMeasured bool, lastGCAt time.Time, lastGCStatus string) Health {
h := Health{
Path: StorePath(cityPath),
SizeBytes: sizeBytes,
LiveRows: retainedRows,
+ RowsMeasured: rowsMeasured,
ThresholdMB: DefaultThresholdMB,
LastGCAt: lastGCAt,
LastGCStatus: lastGCStatus,
}
- if retainedRows > 0 {
+ if rowsMeasured && retainedRows > 0 {
h.RatioMB = float64(sizeBytes) / (bytesPerMB * float64(retainedRows))
h.Warning = sizeBytes > MinWarnSizeBytes && sizeBytes > int64(DefaultThresholdMB*bytesPerMB)*int64(retainedRows)
}
diff --git a/internal/storehealth/storehealth_test.go b/internal/storehealth/storehealth_test.go
index ff29a0fcca..2d737f73bc 100644
--- a/internal/storehealth/storehealth_test.go
+++ b/internal/storehealth/storehealth_test.go
@@ -39,7 +39,7 @@ func TestStorePath_DoltliteMetadata(t *testing.T) {
func TestComputeWarningHighRatio(t *testing.T) {
// 11.2 GB (decimal) / 221 rows = ~50.68 MB/row, warning.
const size = 11_200_000_000
- h := Compute("/c", size, 221, time.Time{}, "")
+ h := Compute("/c", size, 221, true, time.Time{}, "")
if !h.Warning {
t.Fatalf("Warning = false, want true for size=%d rows=221", size)
}
@@ -57,7 +57,7 @@ func TestComputeWarningHighRatio(t *testing.T) {
func TestComputeNoWarningLowRatio(t *testing.T) {
// 50 MB / 221 rows = ~0.23 MB/row, no warning.
const size = 50_000_000
- h := Compute("/c", size, 221, time.Time{}, "")
+ h := Compute("/c", size, 221, true, time.Time{}, "")
if h.Warning {
t.Fatalf("Warning = true, want false for size=%d rows=221", size)
}
@@ -69,19 +69,56 @@ func TestComputeNoWarningLowRatio(t *testing.T) {
func TestComputeZeroRetainedRowsDoesNotWarnForBookkeepingBytes(t *testing.T) {
// The denominator is retained rows (open and closed). A genuinely empty
// store can still contain bookkeeping files, which alone are not unhealthy.
- h := Compute("/c", 1, 0, time.Time{}, "")
+ h := Compute("/c", 1, 0, true, time.Time{}, "")
if h.Warning {
t.Fatalf("Warning = true, want false for bookkeeping bytes with zero retained rows")
}
}
func TestComputeZeroEverything(t *testing.T) {
- h := Compute("/c", 0, 0, time.Time{}, "")
+ h := Compute("/c", 0, 0, true, time.Time{}, "")
if h.Warning {
t.Fatalf("Warning = true, want false for all-zero inputs")
}
}
+// TestComputeUnmeasuredRowsNeverWarns: a row count that failed or
+// timed out must never be treated as a real zero. Even with a large
+// sizeBytes that would trip the ratio warning if 0 retained rows were real,
+// rowsMeasured=false must suppress the warning entirely — there is nothing
+// to compute a ratio against.
+func TestComputeUnmeasuredRowsNeverWarns(t *testing.T) {
+ const size = 11_200_000_000 // would warn at 221 real rows (see TestComputeWarningHighRatio)
+ h := Compute("/c", size, 0, false, time.Time{}, "")
+ if h.Warning {
+ t.Fatalf("Warning = true, want false when rows are unmeasured (RowsMeasured=false)")
+ }
+ if h.RatioMB != 0 {
+ t.Fatalf("RatioMB = %v, want 0 when rows are unmeasured", h.RatioMB)
+ }
+ if h.RowsMeasured {
+ t.Fatalf("RowsMeasured = true, want false")
+ }
+}
+
+// TestComputeUnmeasuredIsDistinguishableFromRealZero pins the actual
+// deliverable: two Health values with identical LiveRows=0 but different
+// RowsMeasured must be distinguishable by callers, so a failed measurement
+// can never render byte-identically to a genuinely empty, healthy store.
+func TestComputeUnmeasuredIsDistinguishableFromRealZero(t *testing.T) {
+ measured := Compute("/c", 1, 0, true, time.Time{}, "")
+ unmeasured := Compute("/c", 1, 0, false, time.Time{}, "")
+ if measured.RowsMeasured == unmeasured.RowsMeasured {
+ t.Fatalf("RowsMeasured did not distinguish a real zero-row count from an unmeasured one")
+ }
+ if !measured.RowsMeasured {
+ t.Fatalf("measured.RowsMeasured = false, want true")
+ }
+ if unmeasured.RowsMeasured {
+ t.Fatalf("unmeasured.RowsMeasured = true, want false")
+ }
+}
+
func TestComputeBoundary(t *testing.T) {
// Exactly at the threshold: size = 1M * rows should NOT warn
// (the inequality is strict ">", not ">=").
@@ -89,11 +126,11 @@ func TestComputeBoundary(t *testing.T) {
// MinWarnSizeBytes, so this exercises the ratio boundary alone,
// not the absolute-size floor (see TestComputeSmallStoreFloor).
const rows = 2000
- h := Compute("/c", int64(DefaultThresholdMB*bytesPerMB)*int64(rows), rows, time.Time{}, "")
+ h := Compute("/c", int64(DefaultThresholdMB*bytesPerMB)*int64(rows), rows, true, time.Time{}, "")
if h.Warning {
t.Fatalf("Warning = true at exact threshold, want false")
}
- h = Compute("/c", int64(DefaultThresholdMB*bytesPerMB)*int64(rows)+1, rows, time.Time{}, "")
+ h = Compute("/c", int64(DefaultThresholdMB*bytesPerMB)*int64(rows)+1, rows, true, time.Time{}, "")
if !h.Warning {
t.Fatalf("Warning = false one byte over threshold, want true")
}
@@ -110,7 +147,7 @@ func TestComputeBoundary(t *testing.T) {
// the total size is still well under the absolute floor.
func TestComputeSmallStoreFloorSuppressesFalsePositive(t *testing.T) {
const size = 343_000_000
- h := Compute("/c", size, 7, time.Time{}, "")
+ h := Compute("/c", size, 7, true, time.Time{}, "")
if h.Warning {
t.Fatalf("Warning = true, want false (343MB/7 rows is below the absolute floor despite a high ratio)")
}
@@ -126,7 +163,7 @@ func TestComputeSmallStoreFloorSuppressesFalsePositive(t *testing.T) {
// are exceeded.
func TestComputeLargeStoreStillWarnsAboveFloor(t *testing.T) {
const size = 11_200_000_000
- h := Compute("/c", size, 221, time.Time{}, "")
+ h := Compute("/c", size, 221, true, time.Time{}, "")
if !h.Warning {
t.Fatalf("Warning = false, want true (11.2GB/221 rows is well above both the ratio threshold and the absolute floor)")
}
@@ -134,7 +171,7 @@ func TestComputeLargeStoreStillWarnsAboveFloor(t *testing.T) {
func TestComputeCarriesLastGC(t *testing.T) {
ts := time.Date(2026, 4, 1, 3, 0, 0, 0, time.UTC)
- h := Compute("/c", 1, 1, ts, "success")
+ h := Compute("/c", 1, 1, true, ts, "success")
if !h.LastGCAt.Equal(ts) {
t.Fatalf("LastGCAt = %v, want %v", h.LastGCAt, ts)
}
diff --git a/internal/supervisor/config.go b/internal/supervisor/config.go
index ee10f89d8d..b969d749a0 100644
--- a/internal/supervisor/config.go
+++ b/internal/supervisor/config.go
@@ -85,6 +85,10 @@ type EventsSection struct {
type ExportConfig struct {
// Endpoint is the HTTP URL that receives batched, envelope-only events.
Endpoint string `toml:"endpoint,omitempty"`
+ // Cities optionally restricts export to exact registered city names. A nil
+ // slice preserves the all-city default; an explicitly empty slice exports no
+ // city events.
+ Cities []string `toml:"cities,omitempty"`
// Token, when set, is sent as an Authorization: Bearer header.
Token string `toml:"token,omitempty"`
// TokenFile, when set, is a path to a file holding the bearer token. It is
diff --git a/internal/supervisor/config_test.go b/internal/supervisor/config_test.go
index 8809fe967e..af0fc3f3d1 100644
--- a/internal/supervisor/config_test.go
+++ b/internal/supervisor/config_test.go
@@ -3,6 +3,7 @@ package supervisor
import (
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
"time"
@@ -160,6 +161,62 @@ policy_ref = "platform-sso"
}
}
+func TestLoadConfigEventExportCities(t *testing.T) {
+ tests := []struct {
+ name string
+ contents string
+ wantNil bool
+ want []string
+ }{
+ {
+ name: "omitted preserves all-city default",
+ contents: `
+[events.export]
+endpoint = "https://example.invalid/ingest"
+`,
+ wantNil: true,
+ },
+ {
+ name: "explicit empty is retained",
+ contents: `
+[events.export]
+endpoint = "https://example.invalid/ingest"
+cities = []
+`,
+ want: []string{},
+ },
+ {
+ name: "configured names retain exact spelling and order",
+ contents: `
+[events.export]
+endpoint = "https://example.invalid/ingest"
+cities = ["north", " south "]
+`,
+ want: []string{"north", " south "},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "supervisor.toml")
+ if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg, err := LoadConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (cfg.Events.Export.Cities == nil) != tt.wantNil {
+ t.Fatalf("Cities nil = %t, want %t", cfg.Events.Export.Cities == nil, tt.wantNil)
+ }
+ if got := cfg.Events.Export.Cities; !slices.Equal(got, tt.want) {
+ t.Fatalf("Cities = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
func TestDefaultHomeWithEnv(t *testing.T) {
t.Setenv("GC_HOME", "/custom/gc")
if got := DefaultHome(); got != "/custom/gc" {
diff --git a/internal/testenv/testdata/gc_env_read_baseline.golden b/internal/testenv/testdata/gc_env_read_baseline.golden
index ef6543c3cd..99bfd5e80b 100644
--- a/internal/testenv/testdata/gc_env_read_baseline.golden
+++ b/internal/testenv/testdata/gc_env_read_baseline.golden
@@ -80,6 +80,12 @@ GC_FORMULA_REF
GC_GIT_CREDENTIALS_FILE
GC_GIT_CREDENTIAL_COMMAND
GC_GRANT_INFO
+GC_HERDR_BOUND_AT
+GC_HERDR_LAUNCH_MODE
+GC_HERDR_PANE_ID
+GC_HERDR_SESSION_NAME
+GC_HERDR_TAB_ID
+GC_HERDR_WORKSPACE_ID
GC_HOME
GC_HOOK_EVENT_NAME
GC_HOOK_SOURCE
diff --git a/internal/testpolicy/resourcecensus/census.go b/internal/testpolicy/resourcecensus/census.go
index 0c80491fe2..bc35db4aaa 100644
--- a/internal/testpolicy/resourcecensus/census.go
+++ b/internal/testpolicy/resourcecensus/census.go
@@ -123,8 +123,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeAll,
Resource: ResourceSubprocess,
- BaselineCalls: 538,
- BaselineFiles: 165,
+ BaselineCalls: 552,
+ BaselineFiles: 168,
ReportedCalls: 495,
ReportedFiles: 135,
OwnerBead: "ga-80po0c.2",
@@ -136,8 +136,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeAll,
Resource: ResourceFixedSleep,
- BaselineCalls: 428,
- BaselineFiles: 156,
+ BaselineCalls: 432,
+ BaselineFiles: 160,
ReportedCalls: 447,
ReportedFiles: 157,
OwnerBead: "ga-80po0c.2",
@@ -164,8 +164,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceSubprocess,
- BaselineCalls: 399,
- BaselineFiles: 114,
+ BaselineCalls: 413,
+ BaselineFiles: 117,
ReportedCalls: 380,
ReportedFiles: 98,
OwnerBead: "ga-80po0c.2",
@@ -177,8 +177,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceFixedSleep,
- BaselineCalls: 289,
- BaselineFiles: 111,
+ BaselineCalls: 287,
+ BaselineFiles: 114,
ReportedCalls: 295,
ReportedFiles: 114,
OwnerBead: "ga-80po0c.2",
@@ -216,7 +216,7 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeCmdGCUntagged,
Resource: ResourceSlowProcessGate,
- BaselineCalls: 58,
+ BaselineCalls: 59,
BaselineFiles: 24,
ReportedCalls: 78,
ReportedFiles: 27,
@@ -255,8 +255,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceNetListen,
- BaselineCalls: 94,
- BaselineFiles: 35,
+ BaselineCalls: 95,
+ BaselineFiles: 36,
ReportedCalls: 92,
ReportedFiles: 34,
OwnerBead: "ga-80po0c.2.2.2",
@@ -407,6 +407,17 @@ var bootstrapPolicy = Ledger{
MigrationTarget: "P0.4b",
Expires: "2026-10-01",
},
+ {
+ PackageDir: "internal/doctor",
+ PackageName: "doctor",
+ Owner: "TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext",
+ Resources: []Resource{ResourceSubprocess},
+ OwnerBead: "ga-8pkpor",
+ Invariant: "doctor custom-types test-owned-HOME dolt-isolation regression proof is a checked Medium owner",
+ ResourceOwner: "the bd subprocess is confined to TestCustomTypesCheck_TableDriftUsesTestOwnedDoltContext, which proves bd routes to an embedded, test-owned dolt store rather than a machine-level shared server",
+ MigrationTarget: "P0.4b",
+ Expires: "2026-10-01",
+ },
},
ReviewedHermeticBody: []ReviewedHermeticBody{
{
@@ -442,8 +453,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceSubprocess,
- BaselineCalls: 394,
- BaselineFiles: 111,
+ BaselineCalls: 407,
+ BaselineFiles: 114,
ReportedCalls: 394,
ReportedFiles: 105,
OwnerBead: "ga-80po0c.2.1",
@@ -455,8 +466,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceFixedSleep,
- BaselineCalls: 289,
- BaselineFiles: 111,
+ BaselineCalls: 287,
+ BaselineFiles: 114,
ReportedCalls: 287,
ReportedFiles: 113,
OwnerBead: "ga-80po0c.2.1",
@@ -494,7 +505,7 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeCmdGCUntagged,
Resource: ResourceSlowProcessGate,
- BaselineCalls: 58,
+ BaselineCalls: 59,
BaselineFiles: 24,
ReportedCalls: 75,
ReportedFiles: 25,
@@ -533,8 +544,8 @@ var bootstrapPolicy = Ledger{
{
Scope: ScopeUntagged,
Resource: ResourceNetListen,
- BaselineCalls: 92,
- BaselineFiles: 34,
+ BaselineCalls: 93,
+ BaselineFiles: 35,
ReportedCalls: 92,
ReportedFiles: 34,
OwnerBead: "ga-80po0c.2.2.2",
@@ -2052,14 +2063,34 @@ const (
// CheckedMarkdownBlock returns the single generated inventory block.
func CheckedMarkdownBlock(document string) (string, error) {
+ start, end, err := markdownBlockSpan(document)
+ if err != nil {
+ return "", err
+ }
+ return document[start:end], nil
+}
+
+// ReplaceMarkdownBlock returns document with its single checked test resource
+// ledger block replaced by replacement. Content outside the marker pair is
+// preserved byte-for-byte. Pass RenderMarkdown's output as replacement to
+// regenerate the block from a Ledger.
+func ReplaceMarkdownBlock(document, replacement string) (string, error) {
+ start, end, err := markdownBlockSpan(document)
+ if err != nil {
+ return "", err
+ }
+ return document[:start] + replacement + document[end:], nil
+}
+
+func markdownBlockSpan(document string) (start, end int, err error) {
if strings.Count(document, markdownBegin) != 1 || strings.Count(document, markdownEnd) != 1 {
- return "", errors.New("TESTING.md must contain exactly one checked test resource ledger marker pair")
+ return 0, 0, errors.New("TESTING.md must contain exactly one checked test resource ledger marker pair")
}
- start := strings.Index(document, markdownBegin)
- end := strings.Index(document, markdownEnd)
+ start = strings.Index(document, markdownBegin)
+ end = strings.Index(document, markdownEnd)
if end < start {
- return "", errors.New("TESTING.md resource ledger end marker precedes begin marker")
+ return 0, 0, errors.New("TESTING.md resource ledger end marker precedes begin marker")
}
end += len(markdownEnd)
- return document[start:end], nil
+ return start, end, nil
}
diff --git a/internal/testpolicy/resourcecensus/census_test.go b/internal/testpolicy/resourcecensus/census_test.go
index d6a96226ed..6dd0d94fd9 100644
--- a/internal/testpolicy/resourcecensus/census_test.go
+++ b/internal/testpolicy/resourcecensus/census_test.go
@@ -1,12 +1,12 @@
package resourcecensus
import (
+ "flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"go/types"
- "io/fs"
"os"
"path/filepath"
"runtime"
@@ -16,6 +16,12 @@ import (
"time"
)
+// updateLedgerDoc regenerates the TESTING.md checked resource ledger block
+// from test/test-resources.toml when set. Run:
+//
+// go test ./internal/testpolicy/resourcecensus -run TestRepositoryLedgerMatchesCensusAndDocumentation -update
+var updateLedgerDoc = flag.Bool("update", false, "regenerate the TESTING.md checked resource ledger block from test/test-resources.toml")
+
func TestScanUsesImportIdentityAndParsedBuildConstraints(t *testing.T) {
t.Parallel()
@@ -1983,12 +1989,12 @@ func TestBootstrapPolicyOwnsNetListenDebtAndExactMediumOwners(t *testing.T) {
t.Parallel()
debt := findRow(t, bootstrapPolicy.Debt, ScopeUntagged, ResourceNetListen)
- if debt.BaselineCalls != 94 || debt.BaselineFiles != 35 || debt.ReportedCalls != 92 || debt.ReportedFiles != 34 {
- t.Fatalf("stream-listener source baseline/reported = %d/%d, %d/%d; want 94/35, 92/34", debt.BaselineCalls, debt.BaselineFiles, debt.ReportedCalls, debt.ReportedFiles)
+ if debt.BaselineCalls != 95 || debt.BaselineFiles != 36 || debt.ReportedCalls != 92 || debt.ReportedFiles != 34 {
+ t.Fatalf("stream-listener source baseline/reported = %d/%d, %d/%d; want 95/36, 92/34", debt.BaselineCalls, debt.BaselineFiles, debt.ReportedCalls, debt.ReportedFiles)
}
smallDebt := findRow(t, bootstrapPolicy.SmallDebt, ScopeUntagged, ResourceNetListen)
- if smallDebt.BaselineCalls != 92 || smallDebt.BaselineFiles != 34 {
- t.Fatalf("stream-listener Small baseline = %d/%d, want 92/34", smallDebt.BaselineCalls, smallDebt.BaselineFiles)
+ if smallDebt.BaselineCalls != 93 || smallDebt.BaselineFiles != 35 {
+ t.Fatalf("stream-listener Small baseline = %d/%d, want 93/35", smallDebt.BaselineCalls, smallDebt.BaselineFiles)
}
for _, row := range []*Baseline{debt, smallDebt} {
if row.OwnerBead != "ga-80po0c.2.2.2" || row.MigrationTarget != "P0.4c-listener" {
@@ -2278,6 +2284,75 @@ func TestCheckedMarkdownBlockRequiresOneOrderedMarkerPair(t *testing.T) {
}
}
+func TestReplaceMarkdownBlockRoundTrips(t *testing.T) {
+ t.Parallel()
+
+ document := "# Title\n\nintro text\n\n" + markdownBegin + "\nstale content\n" + markdownEnd + "\n\ntrailing text\n"
+ replacement := markdownBegin + "\nfresh content\n" + markdownEnd
+
+ updated, err := ReplaceMarkdownBlock(document, replacement)
+ if err != nil {
+ t.Fatalf("ReplaceMarkdownBlock: %v", err)
+ }
+ want := "# Title\n\nintro text\n\n" + replacement + "\n\ntrailing text\n"
+ if updated != want {
+ t.Fatalf("ReplaceMarkdownBlock mismatch\n--- got ---\n%s\n--- want ---\n%s", updated, want)
+ }
+
+ block, err := CheckedMarkdownBlock(updated)
+ if err != nil {
+ t.Fatalf("CheckedMarkdownBlock(updated): %v", err)
+ }
+ if block != replacement {
+ t.Fatalf("round-trip mismatch\n--- got ---\n%s\n--- want ---\n%s", block, replacement)
+ }
+}
+
+func TestGeneratedLedgerBlockRoundTrips(t *testing.T) {
+ t.Parallel()
+
+ ledger := Ledger{
+ Version: 2,
+ AuditBaseline: []Baseline{
+ validAudit(ScopeAll, ResourceFixedSleep, 4, 2),
+ },
+ Debt: []Baseline{
+ validDebt(ScopeUntagged, ResourceSubprocess, 3, 2),
+ },
+ }
+ generated := RenderMarkdown(ledger)
+ document := "# TESTING\n\nsome preamble\n\n" + markdownBegin + "\nold, stale table\n" + markdownEnd + "\n\nmore docs below\n"
+
+ updated, err := ReplaceMarkdownBlock(document, generated)
+ if err != nil {
+ t.Fatalf("ReplaceMarkdownBlock: %v", err)
+ }
+ block, err := CheckedMarkdownBlock(updated)
+ if err != nil {
+ t.Fatalf("CheckedMarkdownBlock(updated): %v", err)
+ }
+ if block != generated {
+ t.Fatalf("generated ledger block did not round-trip\n--- got ---\n%s\n--- want ---\n%s", block, generated)
+ }
+ if !strings.HasPrefix(updated, "# TESTING\n\nsome preamble\n\n") || !strings.HasSuffix(updated, "\n\nmore docs below\n") {
+ t.Fatalf("ReplaceMarkdownBlock altered content outside the marker pair:\n%s", updated)
+ }
+}
+
+func TestReplaceMarkdownBlockRequiresOneOrderedMarkerPair(t *testing.T) {
+ t.Parallel()
+
+ for _, document := range []string{
+ "no markers",
+ markdownEnd + "\n" + markdownBegin,
+ markdownBegin + "\n" + markdownEnd + "\n" + markdownBegin,
+ } {
+ if _, err := ReplaceMarkdownBlock(document, markdownBegin+markdownEnd); err == nil {
+ t.Fatalf("ReplaceMarkdownBlock(%q) unexpectedly succeeded", document)
+ }
+ }
+}
+
func TestRepositoryLedgerMatchesCensusAndDocumentation(t *testing.T) {
root := repositoryRoot(t)
ledger, err := LoadLedger(filepath.Join(root, "test", "test-resources.toml"))
@@ -2292,16 +2367,32 @@ func TestRepositoryLedgerMatchesCensusAndDocumentation(t *testing.T) {
t.Fatalf("resource ledger drift:\n%v", err)
}
- doc, err := fs.ReadFile(os.DirFS(root), "TESTING.md")
+ testingMDPath := filepath.Join(root, "TESTING.md")
+ doc, err := os.ReadFile(testingMDPath)
if err != nil {
t.Fatalf("read TESTING.md: %v", err)
}
+ want := RenderMarkdown(ledger)
+
+ if *updateLedgerDoc {
+ updated, err := ReplaceMarkdownBlock(string(doc), want)
+ if err != nil {
+ t.Fatalf("replace TESTING.md ledger block: %v", err)
+ }
+ if updated != string(doc) {
+ if err := os.WriteFile(testingMDPath, []byte(updated), 0o644); err != nil {
+ t.Fatalf("write TESTING.md: %v", err)
+ }
+ doc = []byte(updated)
+ }
+ }
+
got, err := CheckedMarkdownBlock(string(doc))
if err != nil {
- t.Fatalf("checked TESTING.md block: %v\n--- wanted block ---\n%s", err, RenderMarkdown(ledger))
+ t.Fatalf("checked TESTING.md block: %v\n--- wanted block ---\n%s", err, want)
}
- if want := RenderMarkdown(ledger); got != want {
- t.Fatalf("TESTING.md resource ledger block is stale\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ if got != want {
+ t.Fatalf("TESTING.md resource ledger block is stale; run `go test ./internal/testpolicy/resourcecensus -run TestRepositoryLedgerMatchesCensusAndDocumentation -update` to regenerate it, then review the diff\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
diff --git a/internal/testutil/path.go b/internal/testutil/path.go
index e9a289415d..39ec94da40 100644
--- a/internal/testutil/path.go
+++ b/internal/testutil/path.go
@@ -26,6 +26,22 @@ func AssertSamePath(t *testing.T, got, want string) {
}
}
+// AssertCanonicalPathEquals compares a path a function under test RETURNED
+// against an expectation, normalizing ONLY the expectation.
+//
+// Use this, not AssertSamePath, whenever the function under test is itself
+// responsible for canonicalizing. AssertSamePath normalizes both sides, and
+// CanonicalPath resolves symlinks — so it re-does the very work being asserted
+// and the assertion becomes a tautology that an identity implementation passes.
+// Normalizing only want keeps the darwin /private-alias spelling difference
+// tolerated while still failing on a got that was never resolved.
+func AssertCanonicalPathEquals(t *testing.T, got, want string) {
+ t.Helper()
+ if got != CanonicalPath(want) {
+ t.Fatalf("path = %q, want %q (canonicalized from %q)", got, CanonicalPath(want), want)
+ }
+}
+
// ShortTempDir returns a test-owned temporary directory rooted at a short path
// on macOS so Unix socket paths stay under the platform limit.
func ShortTempDir(t *testing.T, prefix string) string {
diff --git a/internal/worker/builtin/context_opus5_ra_jbbv0_test.go b/internal/worker/builtin/context_opus5_ra_jbbv0_test.go
new file mode 100644
index 0000000000..08dee5c0b5
--- /dev/null
+++ b/internal/worker/builtin/context_opus5_ra_jbbv0_test.go
@@ -0,0 +1,99 @@
+package builtin
+
+import "testing"
+
+// TestBuiltinClaudeModelChoicesIncludeOpus5 is the falsifiable floor for
+// ra-jbbv0 / ra-4cq5w: the builtin claude provider's "model" select is a
+// closed enum, and a value outside it yields no FlagArgs — so gc silently
+// emits no --model flag at all rather than erroring, and 'gc config show'
+// keeps reporting the pin while the launched process runs the provider
+// default model. claude-sonnet-5 (#3867) and claude-fable-5 (#3284) were
+// added to this enum; claude-opus-5 was not.
+func TestBuiltinClaudeModelChoicesIncludeOpus5(t *testing.T) {
+ claude, ok := BuiltinProviders()["claude"]
+ if !ok {
+ t.Fatal("BuiltinProviders() missing claude")
+ }
+
+ var modelOption BuiltinProviderOption
+ for _, option := range claude.OptionsSchema {
+ if option.Key == "model" {
+ modelOption = option
+ break
+ }
+ }
+ if modelOption.Key == "" {
+ t.Fatal("claude provider missing model option")
+ }
+
+ byValue := make(map[string]BuiltinOptionChoice, len(modelOption.Choices))
+ for _, choice := range modelOption.Choices {
+ byValue[choice.Value] = choice
+ }
+
+ choice, ok := byValue["opus-5"]
+ if !ok {
+ t.Fatal("claude model choices missing \"opus-5\" (claude-opus-5 has no enum entry, " +
+ "so resolving it yields no --model FlagArgs and gc silently launches the provider default)")
+ }
+ wantFlagArgs := []string{"--model", "claude-opus-5"}
+ if len(choice.FlagArgs) != 2 || choice.FlagArgs[0] != wantFlagArgs[0] || choice.FlagArgs[1] != wantFlagArgs[1] {
+ t.Errorf("opus-5 FlagArgs = %v, want %v", choice.FlagArgs, wantFlagArgs)
+ }
+ if len(choice.FlagAliases) != 1 || len(choice.FlagAliases[0]) != 2 ||
+ choice.FlagAliases[0][0] != "-m" || choice.FlagAliases[0][1] != "claude-opus-5" {
+ t.Errorf("opus-5 FlagAliases = %v, want [[-m claude-opus-5]]", choice.FlagAliases)
+ }
+
+ // Unlike the sonnet/fable-5 precedent (#3867, #3284), bare "opus" is NOT
+ // repointed at the new latest here: internal/config/provider_test.go
+ // (TestBuiltinProvidersClaudeModelChoices) pins "opus" to claude-opus-4-8
+ // as a deliberate stability guarantee, and opus-5 is added as a new
+ // explicit alias alongside it rather than replacing the default.
+ bare, ok := byValue["opus"]
+ if !ok {
+ t.Fatal("claude model choices missing \"opus\"")
+ }
+ if len(bare.FlagArgs) != 2 || bare.FlagArgs[1] != "claude-opus-4-8" {
+ t.Errorf("opus (bare) FlagArgs = %v, want [--model claude-opus-4-8] (unchanged)", bare.FlagArgs)
+ }
+}
+
+// TestBuiltinClaudeModelChoicesAcceptCanonicalIDsVerbatim is the second half
+// of ra-jbbv0's root cause: operators pin the full provider model ID
+// ("claude-opus-5", not the short alias "opus-5") in agent.toml. The incident
+// showed loial/egwene/siuan/perrin pinned to exactly "claude-opus-5" and
+// moiraine to "claude-opus-5[1m]" — none of which were enum values, so the
+// named-session resolution path hard-errored ("invalid value for model:
+// claude-opus-5") while the launch path silently dropped --model instead.
+// Neither #3867 (Sonnet 5) nor #3284 (Fable 5) added the canonical-id form as
+// an accepted value — only the short alias — so this gap predates and is
+// broader than Opus 5 alone.
+func TestBuiltinClaudeModelChoicesAcceptCanonicalIDsVerbatim(t *testing.T) {
+ claude, ok := BuiltinProviders()["claude"]
+ if !ok {
+ t.Fatal("BuiltinProviders() missing claude")
+ }
+ var modelOption BuiltinProviderOption
+ for _, option := range claude.OptionsSchema {
+ if option.Key == "model" {
+ modelOption = option
+ break
+ }
+ }
+ byValue := make(map[string]BuiltinOptionChoice, len(modelOption.Choices))
+ for _, choice := range modelOption.Choices {
+ byValue[choice.Value] = choice
+ }
+
+ for _, canonical := range []string{"claude-opus-5", "claude-opus-5[1m]", "claude-sonnet-5", "claude-fable-5"} {
+ choice, ok := byValue[canonical]
+ if !ok {
+ t.Errorf("claude model choices missing canonical id %q as a directly-accepted value", canonical)
+ continue
+ }
+ if len(choice.FlagArgs) != 2 || choice.FlagArgs[0] != "--model" || choice.FlagArgs[1] != canonical {
+ t.Errorf("%s FlagArgs = %v, want [--model %s]", canonical, choice.FlagArgs, canonical)
+ }
+ }
+}
diff --git a/internal/worker/builtin/profiles.go b/internal/worker/builtin/profiles.go
index 3853c25ad0..5d53244afa 100644
--- a/internal/worker/builtin/profiles.go
+++ b/internal/worker/builtin/profiles.go
@@ -165,11 +165,29 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{
{Value: "", Label: "Default"},
{Value: "fable-5", Label: "Fable 5", FlagArgs: []string{"--model", "claude-fable-5"}, FlagAliases: [][]string{{"-m", "claude-fable-5"}}},
{Value: "opus", Label: "Opus", FlagArgs: []string{"--model", "claude-opus-4-8"}, FlagAliases: [][]string{{"-m", "claude-opus-4-8"}}},
+ {Value: "opus-5", Label: "Opus 5", FlagArgs: []string{"--model", "claude-opus-5"}, FlagAliases: [][]string{{"-m", "claude-opus-5"}}},
{Value: "opus-4-7", Label: "Opus 4.7", FlagArgs: []string{"--model", "claude-opus-4-7"}, FlagAliases: [][]string{{"-m", "claude-opus-4-7"}}},
{Value: "sonnet", Label: "Sonnet", FlagArgs: []string{"--model", "claude-sonnet-5"}, FlagAliases: [][]string{{"-m", "claude-sonnet-5"}}},
{Value: "sonnet-5", Label: "Sonnet 5", FlagArgs: []string{"--model", "claude-sonnet-5"}, FlagAliases: [][]string{{"-m", "claude-sonnet-5"}}},
{Value: "sonnet-4-6", Label: "Sonnet 4.6", FlagArgs: []string{"--model", "claude-sonnet-4-6"}, FlagAliases: [][]string{{"-m", "claude-sonnet-4-6"}}},
{Value: "haiku", Label: "Haiku", FlagArgs: []string{"--model", "claude-haiku-4-5-20251001"}, FlagAliases: [][]string{{"-m", "claude-haiku-4-5-20251001"}}},
+ // Canonical provider model IDs accepted verbatim. Operators pin the
+ // full "claude-*" id in agent.toml rather than the short alias, and
+ // before these entries existed such a value was not in this enum at
+ // all: the launch path found no FlagArgs and silently emitted NO
+ // --model, while the named-session resolution path hard-errored on
+ // the same value ("invalid value for model: claude-opus-5"). A whole
+ // city ran unpinned for hours on the launch side while four agents
+ // were unwakeable on the resolution side (ra-jbbv0).
+ {Value: "claude-opus-5", Label: "Opus 5 (canonical id)", FlagArgs: []string{"--model", "claude-opus-5"}, FlagAliases: [][]string{{"-m", "claude-opus-5"}}},
+ // The "[1m]" launch suffix is a valid Claude Code model-id form and
+ // operators pin it directly; it is emitted verbatim rather than
+ // normalized down to "claude-opus-5", because silently rewriting an
+ // explicit pin is the same class of surprise these entries exist to
+ // eliminate.
+ {Value: "claude-opus-5[1m]", Label: "Opus 5 1M (canonical id)", FlagArgs: []string{"--model", "claude-opus-5[1m]"}, FlagAliases: [][]string{{"-m", "claude-opus-5[1m]"}}},
+ {Value: "claude-sonnet-5", Label: "Sonnet 5 (canonical id)", FlagArgs: []string{"--model", "claude-sonnet-5"}, FlagAliases: [][]string{{"-m", "claude-sonnet-5"}}},
+ {Value: "claude-fable-5", Label: "Fable 5 (canonical id)", FlagArgs: []string{"--model", "claude-fable-5"}, FlagAliases: [][]string{{"-m", "claude-fable-5"}}},
},
},
},
@@ -511,20 +529,26 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{
ResumeStyle: "subcommand",
},
"opencode": {
- DisplayName: "OpenCode",
- Command: "opencode",
- Args: []string{},
- PromptMode: "flag",
- PromptFlag: "--prompt",
- ReadyDelayMs: 8000,
- ProcessNames: []string{"opencode", "node", "bun"},
- Env: map[string]string{"OPENCODE_PERMISSION": `{"*":"allow"}`},
- SupportsACP: true,
- SupportsHooks: true,
- InstructionsFile: "AGENTS.md",
- ResumeFlag: "--session",
- ResumeStyle: "flag",
- ACPArgs: []string{"acp"},
+ DisplayName: "OpenCode",
+ Command: "opencode",
+ Args: []string{},
+ PromptMode: "flag",
+ PromptFlag: "--prompt",
+ ReadyDelayMs: 8000,
+ ProcessNames: []string{"opencode", "node", "bun"},
+ // OpenCode handles permissions through OPENCODE_PERMISSION and does not
+ // show the Claude/Codex startup dialogs. Without this override, its
+ // process-name hint enables two acceptance passes. Each pass polls
+ // multiple unsupported dialog classes with independent timeouts, so the
+ // first can exhaust the managed startup lease while OpenCode is working.
+ AcceptStartupDialogs: boolPtr(false),
+ Env: map[string]string{"OPENCODE_PERMISSION": `{"*":"allow"}`},
+ SupportsACP: true,
+ SupportsHooks: true,
+ InstructionsFile: "AGENTS.md",
+ ResumeFlag: "--session",
+ ResumeStyle: "flag",
+ ACPArgs: []string{"acp"},
OptionsSchema: []BuiltinProviderOption{
{
Key: "model",
diff --git a/internal/workspacesvc/proxy_process.go b/internal/workspacesvc/proxy_process.go
index af3e447c7b..2468805a94 100644
--- a/internal/workspacesvc/proxy_process.go
+++ b/internal/workspacesvc/proxy_process.go
@@ -223,7 +223,7 @@ func (p *proxyProcessInstance) start(now time.Time) error {
cmd.Env = execenv.WithUsageMetricsDisabled(cmd.Env)
cmd.Stdout = logFile
cmd.Stderr = logFile
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.SysProcAttr = proxyProcessSysProcAttr()
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return fmt.Errorf("start process: %w", err)
diff --git a/internal/workspacesvc/proxy_process_linux.go b/internal/workspacesvc/proxy_process_linux.go
new file mode 100644
index 0000000000..ebff4d5c4d
--- /dev/null
+++ b/internal/workspacesvc/proxy_process_linux.go
@@ -0,0 +1,15 @@
+//go:build linux
+
+package workspacesvc
+
+import "syscall"
+
+// proxyProcessSysProcAttr returns the process attributes used to spawn a
+// proxy_process child. Pdeathsig is kernel-enforced: it fires no matter how
+// the supervisor process ends, including the Go test -timeout watchdog's
+// direct os.Exit (which runs no defer or t.Cleanup anywhere in the
+// process), so it is the only way to guarantee the child does not survive a
+// hard parent exit (ga-9br097).
+func proxyProcessSysProcAttr() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
+}
diff --git a/internal/workspacesvc/proxy_process_other.go b/internal/workspacesvc/proxy_process_other.go
new file mode 100644
index 0000000000..a378ef3d85
--- /dev/null
+++ b/internal/workspacesvc/proxy_process_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package workspacesvc
+
+import "syscall"
+
+// proxyProcessSysProcAttr returns the process attributes used to spawn a
+// proxy_process child. Pdeathsig is Linux-only; non-Linux platforms keep the
+// prior Setpgid-only behavior.
+func proxyProcessSysProcAttr() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{Setpgid: true}
+}
diff --git a/internal/workspacesvc/proxy_process_test.go b/internal/workspacesvc/proxy_process_test.go
index c3428c976d..61c66b95db 100644
--- a/internal/workspacesvc/proxy_process_test.go
+++ b/internal/workspacesvc/proxy_process_test.go
@@ -13,7 +13,10 @@ import (
"os"
"os/exec"
"path/filepath"
+ goruntime "runtime"
+ "strconv"
"strings"
+ "syscall"
"testing"
"time"
@@ -21,6 +24,7 @@ import (
"github.com/gastownhall/gascity/internal/citylayout"
"github.com/gastownhall/gascity/internal/config"
"github.com/gastownhall/gascity/internal/execenv"
+ "github.com/gastownhall/gascity/internal/pidutil"
"github.com/gastownhall/gascity/internal/runtime"
"github.com/gastownhall/gascity/internal/supervisor"
)
@@ -1101,3 +1105,294 @@ func TestManagerTickProxyProcess_RetryRespectsDeadline(t *testing.T) {
t.Fatalf("nextConstructionRetry changed before deadline elapsed: %v -> %v", originalDeadline, deadlineAfter)
}
}
+
+// --- Family A: hard-parent-exit orphan guard (ga-9br097) -----------------
+//
+// Go's per-test -timeout watchdog kills the test binary via a direct
+// os.Exit after dumping goroutine stacks: no defer and no t.Cleanup runs
+// anywhere in the process, in the timed-out goroutine or any other. A
+// proxy_process child spawned before that moment is orphaned — reparented
+// to init — because nothing ever unwinds to call Manager.Close(). The fix
+// has to be kernel-enforced (Pdeathsig) rather than more userspace
+// cleanup, since userspace cleanup structurally cannot run in this
+// scenario.
+
+// proxyProcessInstancePID returns the OS pid of the running helper
+// subprocess backing the named entry, or 0 if it has none. Test-only:
+// reaches into unexported Manager/proxyProcessInstance state directly
+// (same-package white-box access, matching the mgr.entries access already
+// used elsewhere in this file) rather than adding a pid accessor to the
+// public Status type, which carries no PID by design.
+func proxyProcessInstancePID(t *testing.T, mgr *Manager, name string) int {
+ t.Helper()
+ mgr.mu.RLock()
+ e, ok := mgr.entries[name]
+ mgr.mu.RUnlock()
+ if !ok {
+ t.Fatalf("no entry named %q", name)
+ }
+ pp, ok := e.inst.(*proxyProcessInstance)
+ if !ok {
+ t.Fatalf("entry %q instance is %T, want *proxyProcessInstance", name, e.inst)
+ }
+ pp.mu.Lock()
+ defer pp.mu.Unlock()
+ if pp.cmd == nil || pp.cmd.Process == nil {
+ return 0
+ }
+ return pp.cmd.Process.Pid
+}
+
+// TestProxyProcessHardExitHarness is re-exec'd as a subprocess by
+// TestProxyProcessSurvivesHardParentExit. It starts a real proxy_process
+// child, writes that child's pid to GC_HARD_EXIT_PIDFILE, then calls
+// os.Exit directly with zero cleanup — reproducing exactly what the Go
+// test watchdog does on a -timeout kill, deliberately skipping every
+// defer and t.Cleanup in the process (including Manager.Close).
+func TestProxyProcessHardExitHarness(t *testing.T) {
+ if os.Getenv("GC_HARD_EXIT_HARNESS") != "1" {
+ t.Skip("harness process")
+ }
+ setHelperPassthrough(t)
+ exe, err := os.Executable()
+ if err != nil {
+ t.Fatalf("Executable: %v", err)
+ }
+ cityDir := os.Getenv("GC_HARD_EXIT_CITYDIR")
+ if cityDir == "" {
+ t.Fatal("GC_HARD_EXIT_CITYDIR not set")
+ }
+ pidFile := os.Getenv("GC_HARD_EXIT_PIDFILE")
+ if pidFile == "" {
+ t.Fatal("GC_HARD_EXIT_PIDFILE not set")
+ }
+
+ rt := &testRuntime{
+ cityPath: cityDir,
+ cityName: "test-city",
+ cfg: &config.City{
+ Services: []config.Service{{
+ Name: "bridge",
+ Kind: "proxy_process",
+ Process: config.ServiceProcessConfig{
+ Command: []string{exe, "-test.run=^TestProxyProcessHelper$", "--"},
+ HealthPath: "/healthz",
+ },
+ }},
+ },
+ sp: runtime.NewFake(),
+ store: beads.NewMemStore(),
+ }
+ mgr := NewManager(rt)
+ if err := mgr.Reload(); err != nil {
+ t.Fatalf("Reload: %v", err)
+ }
+
+ pid := proxyProcessInstancePID(t, mgr, "bridge")
+ if pid == 0 {
+ t.Fatal("started grandchild has pid 0")
+ }
+ if err := os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0o600); err != nil {
+ t.Fatalf("write pidfile: %v", err)
+ }
+
+ os.Exit(1)
+}
+
+// TestProxyProcessSurvivesHardParentExit is the RED test for ga-9br097's
+// Family A acceptance criterion: a proxy_process child spawned by start()
+// must not survive its parent's hard exit (the Go -timeout watchdog's
+// os.Exit path, which runs no defer/t.Cleanup anywhere in the process).
+// It re-execs this test binary as a harness (TestProxyProcessHardExitHarness)
+// that starts a real child and then os.Exit(1)s with zero cleanup, then
+// asserts the grandchild is gone. start() does not set Pdeathsig today, so
+// this must fail.
+func TestProxyProcessSurvivesHardParentExit(t *testing.T) {
+ if goruntime.GOOS != "linux" {
+ t.Skip("Pdeathsig is Linux-only")
+ }
+ exe, err := os.Executable()
+ if err != nil {
+ t.Fatalf("Executable: %v", err)
+ }
+ stateDir := t.TempDir()
+ pidFile := filepath.Join(stateDir, "grandchild.pid")
+
+ cmd := exec.Command(exe, "-test.run=^TestProxyProcessHardExitHarness$", "--")
+ cmd.Env = append(os.Environ(),
+ "GC_HARD_EXIT_HARNESS=1",
+ "GC_SERVICE_HELPER=1",
+ "GC_HARD_EXIT_CITYDIR="+stateDir,
+ "GC_HARD_EXIT_PIDFILE="+pidFile,
+ )
+ out, runErr := cmd.CombinedOutput()
+ var exitErr *exec.ExitError
+ if runErr != nil && !errors.As(runErr, &exitErr) {
+ t.Fatalf("run harness: %v\n%s", runErr, out)
+ }
+
+ pidBytes, err := os.ReadFile(pidFile)
+ if err != nil {
+ t.Fatalf("harness did not report a grandchild pid (harness output below):\n%s\nerr: %v", out, err)
+ }
+ pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
+ if err != nil {
+ t.Fatalf("parse pidfile %q: %v", pidBytes, err)
+ }
+
+ // Pdeathsig delivery is asynchronous; poll for death rather than
+ // asserting immediately.
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ _ = syscall.Kill(pid, syscall.SIGKILL) // don't leak this test's own reproduction
+ t.Fatalf("grandchild pid %d still alive 5s after harness hard-exited with no cleanup", pid)
+}
+
+// --- Family A: TestMain regression backstop (ga-9br097 ASK 3) ------------
+
+// livingTestChildren returns the pids of any direct child process of this
+// test binary still alive right now, or an error if enumeration itself
+// could not be performed. Every subprocess this package's tests spawn is
+// reaped by the code under test (Manager.Close / stopProcessGroup) before
+// the spawning test returns, so any survivor found after m.Run() means a
+// leak. Enumerates portably via pidutil.ChildPIDs (ps-based) rather than a
+// /proc walk: a /proc-only walk returns nil unconditionally on darwin,
+// which would make the guard below report a false "no leaks" on any
+// platform where it cannot actually look (ga-gxmz9n).
+func livingTestChildren() ([]int, error) {
+ return pidutil.ChildPIDs(os.Getpid())
+}
+
+// shouldFailForLeak decides whether TestMain's exit code must be forced
+// non-zero. An enumeration error means the check did not run at all, and
+// that must never be indistinguishable from a check that ran and found
+// nothing (ga-gxmz9n's binding constraint) — so it fails alongside an
+// actual leak rather than passing silently.
+func shouldFailForLeak(pids []int, err error) (fail bool, reason string) {
+ if err != nil {
+ return true, fmt.Sprintf("leak detection unavailable: %v", err)
+ }
+ if len(pids) > 0 {
+ return true, fmt.Sprintf("%d live child process(es) leaked by tests: %v", len(pids), pids)
+ }
+ return false, ""
+}
+
+// TestMain runs the package's tests, then fails the run if any test left a
+// live direct child process behind (ga-9br097 ASK 3): every subprocess
+// these tests spawn is reaped by the code under test before its owning
+// test returns, so a survivor here is a real leak, not a slow child. It
+// also fails the run if leak detection itself was unavailable, rather than
+// letting that read as a clean pass (ga-gxmz9n).
+func TestMain(m *testing.M) {
+ code := m.Run()
+ pids, err := livingTestChildren()
+ if fail, reason := shouldFailForLeak(pids, err); fail {
+ fmt.Fprintf(os.Stderr, "workspacesvc: %s\n", reason)
+ if code == 0 {
+ code = 1
+ }
+ }
+ os.Exit(code)
+}
+
+// TestShouldFailForLeakOnUnavailableEnumeration is a RED test for
+// ga-gxmz9n's binding constraint: an enumeration error must never be
+// treated as a clean run, even though it also carries zero pids.
+func TestShouldFailForLeakOnUnavailableEnumeration(t *testing.T) {
+ fail, reason := shouldFailForLeak(nil, errors.New("ps: command not found"))
+ if !fail {
+ t.Fatal("shouldFailForLeak(nil, non-nil err) = fail=false, want true — an unavailable check must never look like a clean pass")
+ }
+ if reason == "" {
+ t.Fatal("shouldFailForLeak(nil, non-nil err) returned an empty reason")
+ }
+}
+
+// TestShouldFailForLeakOnLeakedChild covers the pre-existing ga-9br097
+// contract: a live leaked child must fail the run.
+func TestShouldFailForLeakOnLeakedChild(t *testing.T) {
+ fail, reason := shouldFailForLeak([]int{12345}, nil)
+ if !fail {
+ t.Fatal("shouldFailForLeak([pid], nil) = fail=false, want true")
+ }
+ if reason == "" {
+ t.Fatal("shouldFailForLeak([pid], nil) returned an empty reason")
+ }
+}
+
+// TestShouldFailForLeakOnCleanRun asserts a genuinely clean run (enumeration
+// succeeded, zero children) still passes — the fix must not make the guard
+// fail unconditionally.
+func TestShouldFailForLeakOnCleanRun(t *testing.T) {
+ if fail, reason := shouldFailForLeak(nil, nil); fail {
+ t.Fatalf("shouldFailForLeak(nil, nil) = fail=true (reason %q), want false", reason)
+ }
+}
+
+// TestLivingTestChildrenDetectsSurvivor is the RED test for the TestMain
+// regression backstop (ga-9br097 ASK 3): it spawns a real child directly
+// (bypassing Manager/proxy_process entirely, so it exercises only the
+// detector) and asserts livingTestChildren both finds it while alive and
+// stops finding it once killed and reaped. Runs unconditionally on every
+// platform (no macOS skip) — ga-gxmz9n requires real detection on darwin,
+// not a skip standing in for it.
+func TestLivingTestChildrenDetectsSurvivor(t *testing.T) {
+ cmd := exec.Command("sleep", "30")
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start sleep: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ })
+
+ deadline := time.Now().Add(2 * time.Second)
+ var pids []int
+ for time.Now().Before(deadline) {
+ var err error
+ pids, err = livingTestChildren()
+ if err != nil {
+ t.Fatalf("livingTestChildren(): %v", err)
+ }
+ if containsPID(pids, cmd.Process.Pid) {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if !containsPID(pids, cmd.Process.Pid) {
+ t.Fatalf("livingTestChildren() = %v, want to contain live child pid %d", pids, cmd.Process.Pid)
+ }
+
+ if err := cmd.Process.Kill(); err != nil {
+ t.Fatalf("kill: %v", err)
+ }
+ if err := cmd.Wait(); err != nil {
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) {
+ t.Fatalf("wait: %v", err)
+ }
+ }
+
+ pids, err := livingTestChildren()
+ if err != nil {
+ t.Fatalf("livingTestChildren(): %v", err)
+ }
+ if containsPID(pids, cmd.Process.Pid) {
+ t.Fatalf("livingTestChildren() = %v, still contains reaped pid %d", pids, cmd.Process.Pid)
+ }
+}
+
+func containsPID(pids []int, pid int) bool {
+ for _, p := range pids {
+ if p == pid {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/eventexport/exporter.go b/pkg/eventexport/exporter.go
index 619afd1551..248902b837 100644
--- a/pkg/eventexport/exporter.go
+++ b/pkg/eventexport/exporter.go
@@ -27,10 +27,13 @@ type TaggedEvent struct {
Subject string
RunID string
SessionID string
- StepID string // opaque acting-work-bead (run step) id; safeRef-gated at projection (EmitCorrelation)
- Title string // FREE-FORM bead title; emitted only under the content opt-in (Options.emitContent)
- Formula string // FREE-FORM run formula name; emitted only under the content opt-in (Options.emitContent)
- _ struct{} // force keyed literals; blocks positional field transposition
+ StepID string // native execution-step identity (nonblank UTF-8, <=256 bytes; EmitCorrelation)
+ // DependsOnStepIDs is nil when native topology is unknown; an explicit empty
+ // slice represents a known root.
+ DependsOnStepIDs *[]string
+ Title string // FREE-FORM bead title; emitted only under the content opt-in (Options.emitContent)
+ Formula string // FREE-FORM run formula name; emitted only under the content opt-in (Options.emitContent)
+ _ struct{} // force keyed literals; blocks positional field transposition
}
// Source yields tagged events in per-city seq order. The real Source wraps the
@@ -49,7 +52,7 @@ type Config struct {
TokenProvider func() (string, error)
Salt []byte
ExportRef bool
- EmitCorrelation bool // emit opaque run_id/session_id/step_id (default false)
+ EmitCorrelation bool // emit run/session correlation plus native step topology (default false)
Profile Profile
BatchMax int // max events per POST (default 1000)
BatchInterval time.Duration // max time between POSTs (default 5s)
@@ -179,8 +182,8 @@ func (e *Exporter) ingest(te TaggedEvent) {
return // already processed (resume overlap)
}
e.high[te.City] = te.Seq
- // Correlation ids (run_id/session_id/step_id) are emitted only when
- // EmitCorrelation is set (default false), so the projection stays envelope-only
+ // Run/session correlation plus native execution-step topology are emitted only
+ // when EmitCorrelation is set (default false), so the projection stays envelope-only
// unless opted in. The Exporter intentionally exposes no content (title/formula)
// opt-in: the producer path — a reachable Config knob plus the typed source
// fields — is staged behind ga-mt1e99, and the projection's content gate
diff --git a/pkg/eventexport/exporter_test.go b/pkg/eventexport/exporter_test.go
index 96a228e602..21729005a5 100644
--- a/pkg/eventexport/exporter_test.go
+++ b/pkg/eventexport/exporter_test.go
@@ -242,9 +242,7 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// TestExporter_EmitCorrelation proves the end-to-end exported batch carries
-// run_id/session_id only when Config.EmitCorrelation is true (the version-neutral
-// opt-in; SchemaVersion is unchanged since the envelope already defines the fields
-// and they stay omitted by default).
+// run/session/step correlation only when Config.EmitCorrelation is true.
func TestExporter_EmitCorrelation(t *testing.T) {
run := func(emit bool) Batch {
cp := &capture{}
@@ -281,10 +279,10 @@ func TestExporter_EmitCorrelation(t *testing.T) {
if on.Events[0].RunID != "wf-root-abc" || on.Events[0].SessionID != "sess-9f2a" || on.Events[0].StepID != "mc-step-7" {
t.Fatalf("EmitCorrelation=true must carry run/session/step, got %+v", on.Events[0])
}
- // Headline invariant: a v1-pinned receiver accepts the populated batch with no
- // schema mismatch — emitting run/session is v1-compatible (no flag day).
+ // A receiver pinned to this build's schema accepts populated optional
+ // correlation fields without a schema mismatch.
if err := ValidateBatch(on); err != nil {
- t.Fatalf("v1 receiver must accept a populated batch: %v", err)
+ t.Fatalf("receiver must accept a populated batch: %v", err)
}
off := run(false)
diff --git a/pkg/eventexport/golden_test.go b/pkg/eventexport/golden_test.go
index 92c06d25ba..9c29ebe137 100644
--- a/pkg/eventexport/golden_test.go
+++ b/pkg/eventexport/golden_test.go
@@ -38,6 +38,31 @@ func TestGoldenWireBytes(t *testing.T) {
env: Envelope{Seq: 2, Type: "bead.created", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", Ref: "mc-2", RunID: "wf-root-abc", SessionID: "sess-9f2a", StepID: "mc-step-7"},
want: `{"seq":2,"type":"bead.created","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","ref":"mc-2","run_id":"wf-root-abc","session_id":"sess-9f2a","step_id":"mc-step-7"}`,
},
+ {
+ name: "native topology omitted remains unknown",
+ env: Envelope{Seq: 4, Type: "bead.closed", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", StepID: "step-b"},
+ want: `{"seq":4,"type":"bead.closed","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","step_id":"step-b"}`,
+ },
+ {
+ name: "native topology explicit root remains empty array",
+ env: Envelope{Seq: 5, Type: "bead.closed", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", StepID: "step-root", DependsOnStepIDs: slicePtr([]string{})},
+ want: `{"seq":5,"type":"bead.closed","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","step_id":"step-root","depends_on_step_ids":[]}`,
+ },
+ {
+ name: "native topology populated remains ordered array",
+ env: Envelope{Seq: 6, Type: "bead.closed", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", StepID: "step-b", DependsOnStepIDs: slicePtr([]string{"step-a"})},
+ want: `{"seq":6,"type":"bead.closed","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","step_id":"step-b","depends_on_step_ids":["step-a"]}`,
+ },
+ {
+ name: "execution work association retains only physical ref and run",
+ env: Envelope{Seq: 7, Type: "execution.work_associated", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", Ref: "mc-work", RunID: "gcg-root"},
+ want: `{"seq":7,"type":"execution.work_associated","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","ref":"mc-work","run_id":"gcg-root"}`,
+ },
+ {
+ name: "execution step definition retains explicit root topology",
+ env: Envelope{Seq: 8, Type: "execution.step_defined", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", Ref: "gcg-step", RunID: "gcg-root", StepID: "root", DependsOnStepIDs: slicePtr([]string{})},
+ want: `{"seq":8,"type":"execution.step_defined","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","ref":"gcg-step","run_id":"gcg-root","step_id":"root","depends_on_step_ids":[]}`,
+ },
{
// The content opt-in path: free-form title/formula serialize verbatim
// after step_id. Pinning this anchors the off-by-default exemption — the
@@ -60,8 +85,10 @@ func TestGoldenWireBytes(t *testing.T) {
}
}
+func slicePtr(values []string) *[]string { return &values }
+
// TestBatchGoldenBytes pins the batch envelope shape: an opaque city_hash (never
-// a cleartext city name) and schema_version 2.
+// a cleartext city name) and schema_version 4.
func TestBatchGoldenBytes(t *testing.T) {
b := Batch{CityHash: "7f3a9c1e5b2d4068", SchemaVersion: SchemaVersion, Events: []Envelope{
{Seq: 1, Type: "convoy.closed", TS: "2026-06-21T10:03:27Z", ActorHash: "0123456789abcdef", Ref: "gcg-4216"},
@@ -70,7 +97,7 @@ func TestBatchGoldenBytes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- want := `{"city_hash":"7f3a9c1e5b2d4068","schema_version":2,"events":[{"seq":1,"type":"convoy.closed","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","ref":"gcg-4216"}]}`
+ want := `{"city_hash":"7f3a9c1e5b2d4068","schema_version":4,"events":[{"seq":1,"type":"convoy.closed","ts":"2026-06-21T10:03:27Z","actor_hash":"0123456789abcdef","ref":"gcg-4216"}]}`
if string(out) != want {
t.Fatalf("batch golden:\n got %s\nwant %s", out, want)
}
@@ -84,7 +111,8 @@ func TestBatchGoldenBytes(t *testing.T) {
func TestAllowlistPolicyGolden(t *testing.T) {
wantAllowed := []string{
"bead.closed", "bead.created", "controller.started", "convoy.closed",
- "events.rotated", "gc.store.maintenance.done", "mail.sent",
+ "events.rotated", "execution.step_defined", "execution.work_associated",
+ "gc.store.maintenance.done", "mail.sent",
"order.completed", "order.failed", "order.fired",
"project.identity.stamped", "session.drain_acked_with_assigned_work",
"session.draining", "session.reset_stalled", "session.stopped",
@@ -93,7 +121,7 @@ func TestAllowlistPolicyGolden(t *testing.T) {
if got := AllowedTypeList(); !reflect.DeepEqual(got, wantAllowed) {
t.Fatalf("allowlist policy changed:\n got %v\n want %v\n-> update this golden AND bump SchemaVersion", got, wantAllowed)
}
- if got := sortedKeys(refTypes); !reflect.DeepEqual(got, []string{"bead.closed", "bead.created", "convoy.closed"}) {
+ if got := sortedKeys(refTypes); !reflect.DeepEqual(got, []string{"bead.closed", "bead.created", "convoy.closed", "execution.step_defined", "execution.work_associated"}) {
t.Fatalf("refTypes policy changed: got %v -> bump SchemaVersion", got)
}
if got := sortedKeys(mailReduced); !reflect.DeepEqual(got, []string{"mail.sent"}) {
diff --git a/pkg/eventexport/project.go b/pkg/eventexport/project.go
index 0273ee323e..6558a9b1bb 100644
--- a/pkg/eventexport/project.go
+++ b/pkg/eventexport/project.go
@@ -5,7 +5,8 @@
// titles/descriptions, mail bodies, external-message identities, filesystem
// paths). This package never sees that content: a caller hands it only a
// TaggedEvent — the closed set of primitive fields that may ever leave the box
-// (sequence, type, time, actor, subject, and two opaque correlation ids) — and
+// (sequence, type, time, actor, subject, opaque run/session correlation ids,
+// and native execution-step topology) — and
// the projection reduces it to a fixed envelope: type, time, a salted actor
// hash, an id-regex-gated reference, and the opaque run/session ids. An unknown
// or non-allowlisted event type is dropped, and the envelope is a closed struct
@@ -44,7 +45,9 @@ import (
"errors"
"fmt"
"sort"
+ "strings"
"time"
+ "unicode/utf8"
)
// SchemaVersion is stamped on every batch so the receiver can evolve the
@@ -72,9 +75,10 @@ import (
// it implies.
//
// v2 replaced the cleartext city_id with a salted, non-reversible city_hash so
-// an operator-chosen city name (which can itself embed a customer/org
-// identifier) no longer leaves the box.
-const SchemaVersion = 2
+// an operator-chosen city name no longer leaves the box. v3 adds native
+// execution-step dependencies to the envelope. v4 adds fail-closed execution
+// work-association and step-definition facts.
+const SchemaVersion = 4
// Profile selects the redaction profile. There is exactly one today; it is part
// of the public API so Validate can stay profile-aware as profiles are added
@@ -89,9 +93,10 @@ const (
)
const (
- maxRefLen = 64 // run_id/session_id/ref over this are DROPPED, not truncated.
- minSaltLen = 16 // below this the salted actor hash is brute-forceable; fail closed.
- maxContentLen = 256 // free-form title/formula over this are DROPPED, not truncated.
+ maxRefLen = 64 // run_id/session_id/ref over this are DROPPED, not truncated.
+ maxExecutionStepIDLen = 256 // native execution step ids retain their established storage domain.
+ minSaltLen = 16 // below this the salted actor hash is brute-forceable; fail closed.
+ maxContentLen = 256 // free-form title/formula over this are DROPPED, not truncated.
)
// allowedTypes is the default-deny allowlist of exportable event types, keyed by
@@ -115,6 +120,8 @@ var allowedTypes = map[string]bool{
"convoy.closed": true,
"controller.started": true,
"events.rotated": true,
+ "execution.step_defined": true,
+ "execution.work_associated": true,
"session.drain_acked_with_assigned_work": true,
"session.reset_stalled": true,
"project.identity.stamped": true,
@@ -133,9 +140,11 @@ var mailReduced = map[string]bool{"mail.sent": true}
// session/rig name, a hostname) is free of paths, author text, or third-party
// identifiers, so we never emit one.
var refTypes = map[string]bool{
- "bead.created": true,
- "bead.closed": true,
- "convoy.closed": true,
+ "bead.created": true,
+ "bead.closed": true,
+ "convoy.closed": true,
+ "execution.step_defined": true,
+ "execution.work_associated": true,
}
// IsAllowed reports whether an event type is on the export allowlist.
@@ -166,7 +175,10 @@ type Envelope struct {
Ref string `json:"ref,omitempty"` // id-regex-gated reference (opaque id/slug only)
RunID string `json:"run_id,omitempty"` // opaque run-root correlation id (safeRef-gated)
SessionID string `json:"session_id,omitempty"` // opaque session correlation id (safeRef-gated)
- StepID string `json:"step_id,omitempty"` // opaque acting-work-bead (run step) id; safeRef-gated, EmitCorrelation
+ StepID string `json:"step_id,omitempty"` // native execution-step identity (nonblank UTF-8, <=256 bytes), EmitCorrelation
+ // DependsOnStepIDs is nil when native topology is unknown. A present empty
+ // slice is a known native root; a non-empty slice is strictly sorted and unique.
+ DependsOnStepIDs *[]string `json:"depends_on_step_ids,omitempty"`
// Title/Formula are the DELIBERATE exception to envelope-only: free-form content
// (a bead's human title; a run's formula name), gated by the package-internal
// content opt-in (Options.emitContent), length-capped (dropped, not truncated),
@@ -194,7 +206,7 @@ type Batch struct {
// the envelope-only default, and keeping it package-private is what makes the
// SchemaVersion no-bump exemption sound. An out-of-package importer constructs
// Options with keyed literals and so CANNOT enable content, which means no caller
-// of the exported ProjectEvent can emit Title/Formula on a SchemaVersion==2
+// of the exported ProjectEvent can emit Title/Formula on a SchemaVersion==4
// batch. The field exists only for in-package projection tests and the future
// producer path (ga-mt1e99), which owns exposing a reachable opt-in and the
// SchemaVersion decision that reachable content egress then requires.
@@ -202,7 +214,7 @@ type Options struct {
Salt []byte // actor-hash salt; must be >= 16 bytes (ProjectEvent fails closed otherwise)
ExportRef bool // include the id-gated ref (opaque ids/slugs only)
Profile Profile // redaction profile (default ProfileRedactedEnvelope)
- EmitCorrelation bool // emit opaque run_id/session_id/step_id; default false (the production export sets it true)
+ EmitCorrelation bool // emit run/session correlation and native step topology; default false (the production export sets it true)
emitContent bool // emit free-form Title/Formula; default false. REVERSES the envelope-only default. UNEXPORTED so no out-of-package caller can enable content egress; the reachable producer opt-in is staged (ga-mt1e99).
}
@@ -259,6 +271,12 @@ func ProjectEvent(te TaggedEvent, opt Options) (Envelope, bool) {
if len(opt.Salt) < minSaltLen {
return Envelope{}, false
}
+ if te.DependsOnStepIDs != nil && !opt.EmitCorrelation {
+ return Envelope{}, false
+ }
+ if executionFactTypes[te.Type] {
+ return projectExecutionFact(te, opt)
+ }
env := Envelope{Seq: te.Seq, Type: te.Type, TS: te.Ts.UTC().Format(time.RFC3339Nano)}
if mailReduced[te.Type] {
return env, true // {type, ts} only
@@ -276,8 +294,15 @@ func ProjectEvent(te TaggedEvent, opt Options) (Envelope, bool) {
if s := safeRef(te.SessionID); s != "" {
env.SessionID = s
}
- if st := safeRef(te.StepID); st != "" {
+ if st := validExecutionStepID(te.StepID); st != "" {
env.StepID = st
+ deps, ok := normalizeStepDependencies(st, te.DependsOnStepIDs)
+ if !ok {
+ return Envelope{}, false
+ }
+ env.DependsOnStepIDs = deps
+ } else if te.DependsOnStepIDs != nil {
+ return Envelope{}, false
}
}
// Content fields are the deliberate exception to the envelope-only default:
@@ -295,6 +320,50 @@ func ProjectEvent(te TaggedEvent, opt Options) (Envelope, bool) {
return env, true
}
+var executionFactTypes = map[string]bool{
+ "execution.work_associated": true,
+ "execution.step_defined": true,
+}
+
+func projectExecutionFact(te TaggedEvent, opt Options) (Envelope, bool) {
+ if !opt.EmitCorrelation || !opt.ExportRef || te.SessionID != "" || te.Title != "" || te.Formula != "" {
+ return Envelope{}, false
+ }
+ ref, runID := safeRef(te.Subject), safeRef(te.RunID)
+ if ref == "" || runID == "" {
+ return Envelope{}, false
+ }
+ env := Envelope{
+ Seq: te.Seq,
+ Type: te.Type,
+ TS: te.Ts.UTC().Format(time.RFC3339Nano),
+ ActorHash: ActorHash(opt.Salt, te.Actor),
+ Ref: ref,
+ RunID: runID,
+ }
+ switch te.Type {
+ case "execution.work_associated":
+ if te.StepID != "" || te.DependsOnStepIDs != nil {
+ return Envelope{}, false
+ }
+ case "execution.step_defined":
+ stepID := validExecutionStepID(te.StepID)
+ if stepID == "" {
+ return Envelope{}, false
+ }
+ dependencies, ok := normalizeStepDependencies(stepID, te.DependsOnStepIDs)
+ if !ok {
+ return Envelope{}, false
+ }
+ env.StepID = stepID
+ env.DependsOnStepIDs = dependencies
+ }
+ return env, true
+}
+
+// ErrInvalidStepTopology reports malformed native execution-step dependencies.
+var ErrInvalidStepTopology = errors.New("eventexport: invalid step topology")
+
// ValidateEnvelope re-asserts the wire-authoritative redaction invariants on a
// projected envelope, with NO producer configuration. It is the trust-boundary
// check a receiver runs on each row it ingests: ExportRef is a producer-side knob
@@ -312,7 +381,7 @@ func ValidateEnvelope(env Envelope) error {
return fmt.Errorf("eventexport: invalid ts %q", env.TS)
}
if mailReduced[env.Type] {
- if env.ActorHash != "" || env.Ref != "" || env.RunID != "" || env.SessionID != "" || env.StepID != "" || env.Title != "" || env.Formula != "" {
+ if env.ActorHash != "" || env.Ref != "" || env.RunID != "" || env.SessionID != "" || env.StepID != "" || env.DependsOnStepIDs != nil || env.Title != "" || env.Formula != "" {
return fmt.Errorf("eventexport: %q must carry only {seq,type,ts}", env.Type)
}
return nil
@@ -334,8 +403,16 @@ func ValidateEnvelope(env Envelope) error {
if env.SessionID != "" && !IsOpaqueRef(env.SessionID) {
return fmt.Errorf("eventexport: session_id %q is not an opaque id", env.SessionID)
}
- if env.StepID != "" && !IsOpaqueRef(env.StepID) {
- return fmt.Errorf("eventexport: step_id %q is not an opaque id", env.StepID)
+ if env.StepID != "" && validExecutionStepID(env.StepID) == "" {
+ return fmt.Errorf("eventexport: step_id exceeds the execution-step domain")
+ }
+ if err := validateStepDependencies(env.StepID, env.DependsOnStepIDs); err != nil {
+ return err
+ }
+ if executionFactTypes[env.Type] {
+ if err := validateExecutionFact(env); err != nil {
+ return err
+ }
}
// Title/Formula are free-form content (the content opt-in exception): the wire
// invariant is a length bound, NOT opaqueness — charset is unrestricted.
@@ -348,6 +425,26 @@ func ValidateEnvelope(env Envelope) error {
return nil
}
+func validateExecutionFact(env Envelope) error {
+ if env.Ref == "" || env.RunID == "" {
+ return fmt.Errorf("eventexport: %q requires nonempty ref and run_id", env.Type)
+ }
+ if env.SessionID != "" || env.Title != "" || env.Formula != "" {
+ return fmt.Errorf("eventexport: %q must not carry session_id or content", env.Type)
+ }
+ switch env.Type {
+ case "execution.work_associated":
+ if env.StepID != "" || env.DependsOnStepIDs != nil {
+ return fmt.Errorf("eventexport: %q must not carry step topology", env.Type)
+ }
+ case "execution.step_defined":
+ if env.StepID == "" {
+ return fmt.Errorf("eventexport: %q requires step_id", env.Type)
+ }
+ }
+ return nil
+}
+
// Validate is the producer's defense-in-depth self-check: ValidateEnvelope plus
// the producer-only policies that a ref is present only when opt.ExportRef is set
// and that free-form Title/Formula are present only when opt.emitContent is set,
@@ -382,7 +479,7 @@ var ErrSchemaMismatch = errors.New("eventexport: batch schema_version mismatch")
// ValidateBatch checks a received batch end to end: its schema_version must equal
// SchemaVersion (else it returns an error wrapping ErrSchemaMismatch), its
-// city_hash must be the opaque 16-hex partition-key shape that schema v2 promises
+// city_hash must retain the opaque 16-hex partition-key shape introduced in v2
// (rejecting empty, cleartext, or otherwise malformed values at the receiver trust
// boundary, the same shape gate ValidateEnvelope applies to actor_hash), then every
// envelope must pass ValidateEnvelope. Validation is fail-fast: it returns the
@@ -402,6 +499,51 @@ func ValidateBatch(b Batch) error {
return nil
}
+func normalizeStepDependencies(stepID string, dependencies *[]string) (*[]string, bool) {
+ if dependencies == nil {
+ return nil, true
+ }
+ normalized := append([]string{}, (*dependencies)...)
+ sort.Strings(normalized)
+ if err := validateStepDependencies(stepID, &normalized); err != nil {
+ return nil, false
+ }
+ return &normalized, true
+}
+
+func validateStepDependencies(stepID string, dependencies *[]string) error {
+ if dependencies == nil {
+ return nil
+ }
+ if stepID == "" {
+ return fmt.Errorf("%w: depends_on_step_ids requires step_id", ErrInvalidStepTopology)
+ }
+ previous := ""
+ for _, dependency := range *dependencies {
+ if validExecutionStepID(dependency) == "" {
+ return fmt.Errorf("%w: dependency exceeds the execution-step domain", ErrInvalidStepTopology)
+ }
+ if dependency == stepID {
+ return fmt.Errorf("%w: step cannot depend on itself", ErrInvalidStepTopology)
+ }
+ if previous != "" && dependency <= previous {
+ return fmt.Errorf("%w: dependencies must be strictly sorted and unique", ErrInvalidStepTopology)
+ }
+ previous = dependency
+ }
+ return nil
+}
+
+// validExecutionStepID preserves the existing execution_step_id domain. It is
+// intentionally separate from safeRef: native step ids are opaque application
+// values, not the lowercase 64-byte correlation slugs used by run/session/ref.
+func validExecutionStepID(id string) string {
+ if len(id) > maxExecutionStepIDLen || !utf8.ValidString(id) || strings.TrimSpace(id) == "" {
+ return ""
+ }
+ return id
+}
+
// IsOpaqueRef reports whether s is a non-empty opaque lowercase id/slug (the
// shape safeRef accepts): the single importable definition every rail shares for
// an opaque correlation id. Values over 64 bytes are not opaque (dropped, not
diff --git a/pkg/eventexport/project_test.go b/pkg/eventexport/project_test.go
index be30b301b9..87dfc9bca0 100644
--- a/pkg/eventexport/project_test.go
+++ b/pkg/eventexport/project_test.go
@@ -2,6 +2,8 @@ package eventexport
import (
"encoding/json"
+ "fmt"
+ "reflect"
"strings"
"testing"
"time"
@@ -124,9 +126,8 @@ func TestProjectEvent_RunSessionGating(t *testing.T) {
}
}
-// step_id (the acting work bead) is gated exactly like run/session: EmitCorrelation
-// fail-closed, safeRef-opaque-only, never on mail-reduced types, empty when the
-// subject bead carries no gc.step_id.
+// step_id is native execution identity: it uses its established nonblank,
+// 256-byte domain rather than the 64-byte lowercase correlation-slug gate.
func TestProjectEvent_StepIDGating(t *testing.T) {
te := func(step string) TaggedEvent {
return TaggedEvent{Seq: 1, Type: "bead.closed", Ts: fixedTS, Actor: "gc", Subject: "mc-1", RunID: "wf-root-abc", SessionID: "sess-9f2a", StepID: step}
@@ -136,12 +137,12 @@ func TestProjectEvent_StepIDGating(t *testing.T) {
t.Fatalf("EmitCorrelation=false must drop step_id, got %q", g.StepID)
}
on := Options{Salt: testSalt, ExportRef: true, EmitCorrelation: true}
- if g, ok := ProjectEvent(te("mc-step-7"), on); !ok || g.StepID != "mc-step-7" {
- t.Fatalf("opaque step_id must round-trip when emitted, got %q ok=%v", g.StepID, ok)
+ if g, ok := ProjectEvent(te("Step A / provider:value"), on); !ok || g.StepID != "Step A / provider:value" {
+ t.Fatalf("native step_id must retain its established domain, got %q ok=%v", g.StepID, ok)
}
- for _, bad := range []string{"gascity/codex", "user@host", "Up Per", "a b"} {
+ for _, bad := range []string{"", " ", strings.Repeat("x", 257)} {
if g, _ := ProjectEvent(te(bad), on); g.StepID != "" {
- t.Fatalf("non-opaque step_id %q must drop to empty, got %q", bad, g.StepID)
+ t.Fatalf("invalid execution step_id %q must drop to empty, got %q", bad, g.StepID)
}
}
mail := te("mc-step-7")
@@ -155,6 +156,123 @@ func TestProjectEvent_StepIDGating(t *testing.T) {
}
}
+func TestProjectEventNormalizesNativeStepDependencies(t *testing.T) {
+ deps := []string{"step-c", "step-a"}
+ env, ok := ProjectEvent(TaggedEvent{
+ Seq: 1, Type: "bead.closed", Ts: fixedTS, Actor: "gc", Subject: "mc-1",
+ StepID: "step-b", DependsOnStepIDs: &deps,
+ }, Options{Salt: testSalt, EmitCorrelation: true})
+ if !ok || env.DependsOnStepIDs == nil {
+ t.Fatalf("ProjectEvent() = %+v, %v; want emitted topology", env, ok)
+ }
+ if got, want := *env.DependsOnStepIDs, []string{"step-a", "step-c"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("depends_on_step_ids = %v, want %v", got, want)
+ }
+ if env.DependsOnStepIDs == &deps {
+ t.Fatal("ProjectEvent retained caller-owned dependency slice")
+ }
+
+ root := []string{}
+ env, ok = ProjectEvent(TaggedEvent{
+ Seq: 2, Type: "bead.closed", Ts: fixedTS, Actor: "gc", Subject: "mc-2",
+ StepID: "step-root", DependsOnStepIDs: &root,
+ }, Options{Salt: testSalt, EmitCorrelation: true})
+ if !ok || env.DependsOnStepIDs == nil || len(*env.DependsOnStepIDs) != 0 {
+ t.Fatalf("explicit root = %+v, %v; want present empty dependency list", env, ok)
+ }
+ wire, err := json.Marshal(env)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(wire), `"depends_on_step_ids":[]`) {
+ t.Fatalf("explicit root wire = %s; want empty dependency array", wire)
+ }
+}
+
+func TestProjectEventExecutionFactsFailClosed(t *testing.T) {
+ on := Options{Salt: testSalt, ExportRef: true, EmitCorrelation: true}
+ work := TaggedEvent{
+ Seq: 1, Type: "execution.work_associated", Ts: fixedTS, Actor: "graph", Subject: "mc-work", RunID: "gcg-root",
+ }
+ if got, ok := ProjectEvent(work, on); !ok || got.Ref != "mc-work" || got.RunID != "gcg-root" || got.SessionID != "" || got.StepID != "" || got.DependsOnStepIDs != nil {
+ t.Fatalf("work association = %#v, %v; want exact envelope-only association", got, ok)
+ }
+
+ for _, tc := range []struct {
+ name string
+ deps *[]string
+ }{
+ {name: "unknown"},
+ {name: "root", deps: &[]string{}},
+ {name: "dependencies", deps: &[]string{"root"}},
+ } {
+ t.Run("step "+tc.name, func(t *testing.T) {
+ step := TaggedEvent{
+ Seq: 2, Type: "execution.step_defined", Ts: fixedTS, Actor: "graph", Subject: "gcg-step", RunID: "gcg-root", StepID: "build", DependsOnStepIDs: tc.deps,
+ }
+ got, ok := ProjectEvent(step, on)
+ if !ok || got.Ref != "gcg-step" || got.RunID != "gcg-root" || got.StepID != "build" || !reflect.DeepEqual(got.DependsOnStepIDs, tc.deps) {
+ t.Fatalf("step definition = %#v, %v; want topology %#v", got, ok, tc.deps)
+ }
+ if tc.deps != nil && got.DependsOnStepIDs == tc.deps {
+ t.Fatal("step definition retained caller-owned topology")
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ name string
+ event TaggedEvent
+ opt Options
+ }{
+ {name: "correlation disabled", event: work, opt: Options{Salt: testSalt, ExportRef: true}},
+ {name: "ref disabled", event: work, opt: Options{Salt: testSalt, EmitCorrelation: true}},
+ {name: "work missing subject", event: TaggedEvent{Seq: 3, Type: "execution.work_associated", Ts: fixedTS, RunID: "gcg-root"}, opt: on},
+ {name: "work missing run", event: TaggedEvent{Seq: 4, Type: "execution.work_associated", Ts: fixedTS, Subject: "mc-work"}, opt: on},
+ {name: "work includes session", event: TaggedEvent{Seq: 5, Type: "execution.work_associated", Ts: fixedTS, Subject: "mc-work", RunID: "gcg-root", SessionID: "gcs-1"}, opt: on},
+ {name: "work includes step", event: TaggedEvent{Seq: 6, Type: "execution.work_associated", Ts: fixedTS, Subject: "mc-work", RunID: "gcg-root", StepID: "step"}, opt: on},
+ {name: "work includes topology", event: TaggedEvent{Seq: 7, Type: "execution.work_associated", Ts: fixedTS, Subject: "mc-work", RunID: "gcg-root", DependsOnStepIDs: &[]string{}}, opt: on},
+ {name: "step missing subject", event: TaggedEvent{Seq: 8, Type: "execution.step_defined", Ts: fixedTS, RunID: "gcg-root", StepID: "root"}, opt: on},
+ {name: "step missing run", event: TaggedEvent{Seq: 9, Type: "execution.step_defined", Ts: fixedTS, Subject: "gcg-step", StepID: "root"}, opt: on},
+ {name: "step missing semantic id", event: TaggedEvent{Seq: 10, Type: "execution.step_defined", Ts: fixedTS, Subject: "gcg-step", RunID: "gcg-root"}, opt: on},
+ {name: "step includes session", event: TaggedEvent{Seq: 11, Type: "execution.step_defined", Ts: fixedTS, Subject: "gcg-step", RunID: "gcg-root", SessionID: "gcs-1", StepID: "root"}, opt: on},
+ {name: "step includes content", event: TaggedEvent{Seq: 12, Type: "execution.step_defined", Ts: fixedTS, Subject: "gcg-step", RunID: "gcg-root", StepID: "root", Title: "free form"}, opt: on},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got, ok := ProjectEvent(tc.event, tc.opt); ok {
+ t.Fatalf("ProjectEvent() = %#v, true; want drop", got)
+ }
+ })
+ }
+}
+
+func TestProjectEventRejectsInvalidPresentNativeTopology(t *testing.T) {
+ deps := []string{"step-a", "step-a"}
+ if _, ok := ProjectEvent(TaggedEvent{
+ Seq: 1, Type: "bead.closed", Ts: fixedTS, Actor: "gc", Subject: "mc-1",
+ StepID: "step-b", DependsOnStepIDs: &deps,
+ }, Options{Salt: testSalt, EmitCorrelation: true}); ok {
+ t.Fatal("ProjectEvent emitted invalid present topology")
+ }
+}
+
+func TestProjectEventAcceptsMoreThanSixtyFourNativeDependencies(t *testing.T) {
+ deps := make([]string, 65)
+ for i := range deps {
+ deps[i] = fmt.Sprintf("dependency-%03d", i)
+ }
+ env, ok := ProjectEvent(TaggedEvent{
+ Seq: 1, Type: "bead.closed", Ts: fixedTS, Actor: "gc", Subject: "mc-1",
+ StepID: "target", DependsOnStepIDs: &deps,
+ }, Options{Salt: testSalt, EmitCorrelation: true})
+ if !ok || env.DependsOnStepIDs == nil || len(*env.DependsOnStepIDs) != len(deps) {
+ t.Fatalf("ProjectEvent() = %+v, %v; want all %d dependencies", env, ok, len(deps))
+ }
+ if err := ValidateEnvelope(env); err != nil {
+ t.Fatalf("ValidateEnvelope() = %v, want accepted unbounded topology", err)
+ }
+}
+
// TestProject_NoLeak feeds the projection a corpus carrying the sensitive markers
// the raw stream holds — in the primitive fields the projection actually receives
// — and proves none survive into the marshaled batch. The adapter-level
diff --git a/pkg/eventexport/validate_test.go b/pkg/eventexport/validate_test.go
index ae68dc15ff..1361e90be0 100644
--- a/pkg/eventexport/validate_test.go
+++ b/pkg/eventexport/validate_test.go
@@ -38,6 +38,40 @@ func TestValidateEnvelope_AcceptsRefWithoutOptions(t *testing.T) {
}
}
+func TestValidateEnvelopeExecutionFactsFailClosed(t *testing.T) {
+ valid := []Envelope{
+ {Seq: 1, Type: "execution.work_associated", TS: rfc(t), Ref: "mc-work", RunID: "gcg-root"},
+ {Seq: 2, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", StepID: "root"},
+ {Seq: 3, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", StepID: "root", DependsOnStepIDs: &[]string{}},
+ {Seq: 4, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", StepID: "build", DependsOnStepIDs: &[]string{"root"}},
+ }
+ for _, env := range valid {
+ if err := ValidateEnvelope(env); err != nil {
+ t.Fatalf("valid execution fact rejected: %+v: %v", env, err)
+ }
+ }
+
+ for name, env := range map[string]Envelope{
+ "work missing ref": {Seq: 5, Type: "execution.work_associated", TS: rfc(t), RunID: "gcg-root"},
+ "work missing run": {Seq: 6, Type: "execution.work_associated", TS: rfc(t), Ref: "mc-work"},
+ "work session": {Seq: 7, Type: "execution.work_associated", TS: rfc(t), Ref: "mc-work", RunID: "gcg-root", SessionID: "gcs-1"},
+ "work step": {Seq: 8, Type: "execution.work_associated", TS: rfc(t), Ref: "mc-work", RunID: "gcg-root", StepID: "step"},
+ "work topology": {Seq: 9, Type: "execution.work_associated", TS: rfc(t), Ref: "mc-work", RunID: "gcg-root", DependsOnStepIDs: &[]string{}},
+ "step missing ref": {Seq: 10, Type: "execution.step_defined", TS: rfc(t), RunID: "gcg-root", StepID: "step"},
+ "step missing run": {Seq: 11, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", StepID: "step"},
+ "step missing id": {Seq: 12, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root"},
+ "step session": {Seq: 13, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", SessionID: "gcs-1", StepID: "step"},
+ "step title": {Seq: 14, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", StepID: "step", Title: "free form"},
+ "step formula": {Seq: 15, Type: "execution.step_defined", TS: rfc(t), Ref: "gcg-step", RunID: "gcg-root", StepID: "step", Formula: "free form"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := ValidateEnvelope(env); err == nil {
+ t.Fatal("ValidateEnvelope accepted unusable execution fact")
+ }
+ })
+ }
+}
+
func TestValidateEnvelope_Rejects(t *testing.T) {
cases := map[string]Envelope{
"unknown type": {Seq: 1, Type: "extmsg.inbound", TS: rfc(t)},
@@ -48,7 +82,7 @@ func TestValidateEnvelope_Rejects(t *testing.T) {
"non-opaque ref": {Seq: 1, Type: "bead.closed", TS: rfc(t), Ref: "a/b"},
"non-opaque run_id": {Seq: 1, Type: "bead.closed", TS: rfc(t), RunID: "a/b"},
"non-opaque session": {Seq: 1, Type: "bead.closed", TS: rfc(t), SessionID: "A@b"},
- "non-opaque step_id": {Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "a/b"},
+ "over-cap step_id": {Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: strings.Repeat("x", maxExecutionStepIDLen+1)},
"mail with extras": {Seq: 1, Type: "mail.sent", TS: rfc(t), ActorHash: "0123456789abcdef"},
"mail with step_id": {Seq: 1, Type: "mail.sent", TS: rfc(t), StepID: "mc-step-1"},
// Receiver-side content trust boundary: the length cap and the
@@ -117,8 +151,8 @@ func TestValidateBatch(t *testing.T) {
t.Fatalf("schema skew must wrap ErrSchemaMismatch, got %v", err)
}
- // Receiver trust boundary: city_hash must be the opaque 16-hex partition-key
- // shape schema v2 promises. An empty, too-short, cleartext-shaped, uppercase,
+ // Receiver trust boundary: city_hash retains the opaque 16-hex partition-key
+ // shape introduced in schema v2. An empty, too-short, cleartext-shaped, uppercase,
// or over-length value is rejected before any row is processed — the receiver
// cannot assume the producer redacted the operator-chosen city name.
for name, ch := range map[string]string{
@@ -151,6 +185,30 @@ func TestValidateBatch(t *testing.T) {
}
}
+func TestValidateEnvelopeNativeStepDependencies(t *testing.T) {
+ root := []string{}
+ for _, tc := range []struct {
+ name string
+ env Envelope
+ want error
+ }{
+ {name: "omitted is unknown", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-a"}},
+ {name: "explicit empty is known root", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-a", DependsOnStepIDs: &root}},
+ {name: "sorted unique dependencies", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-b", DependsOnStepIDs: &[]string{"step-a", "step-c"}}},
+ {name: "dependencies require step", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), DependsOnStepIDs: &[]string{"step-a"}}, want: ErrInvalidStepTopology},
+ {name: "duplicate dependency", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-b", DependsOnStepIDs: &[]string{"step-a", "step-a"}}, want: ErrInvalidStepTopology},
+ {name: "out of order dependency", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-c", DependsOnStepIDs: &[]string{"step-b", "step-a"}}, want: ErrInvalidStepTopology},
+ {name: "self dependency", env: Envelope{Seq: 1, Type: "bead.closed", TS: rfc(t), StepID: "step-a", DependsOnStepIDs: &[]string{"step-a"}}, want: ErrInvalidStepTopology},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateEnvelope(tc.env)
+ if !errors.Is(err, tc.want) {
+ t.Fatalf("ValidateEnvelope() error = %v, want %v", err, tc.want)
+ }
+ })
+ }
+}
+
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
}
@@ -180,20 +238,21 @@ func TestProfileZeroValue(t *testing.T) {
// author to gate it in ProjectEvent + ValidateEnvelope (and bump SchemaVersion if
// the wire changes) rather than letting it ship ungated.
func TestEnvelopeFieldCount(t *testing.T) {
- // 11 = the original 7 + StepID (a version-NEUTRAL opaque correlation field,
- // gated in ProjectEvent + ValidateEnvelope exactly like run_id/session_id) +
+ // 12 = the original 7 + StepID (a version-NEUTRAL native execution identity,
+ // gated in ProjectEvent + ValidateEnvelope) +
// Title + Formula (free-form content under the content opt-in — the deliberate
// exception to envelope-only, gated separately and length-capped, never
// opaque-gated) + the trailing blank `_ struct{}` keyed-literal guard, which is
- // NOT a wire field (json ignores it; it only forces keyed Envelope literals).
- if n := reflect.TypeOf(Envelope{}).NumField(); n != 11 {
+ // NOT a wire field (json ignores it; it only forces keyed Envelope literals),
+ // plus the optional DependsOnStepIDs topology field.
+ if n := reflect.TypeOf(Envelope{}).NumField(); n != 12 {
t.Fatalf("Envelope has %d fields; a field changed — gate it in ProjectEvent and ValidateEnvelope, then update this guard (and bump SchemaVersion if the wire changes)", n)
}
}
// TestOptionsContentOptInUnexported locks the content opt-in as package-private.
// If emitContent were exported, any importer of pkg/eventexport could call
-// ProjectEvent with content enabled and emit Title/Formula on a SchemaVersion==2
+// ProjectEvent with content enabled and emit Title/Formula on a SchemaVersion==4
// batch — exactly the reachable wire change the off-by-default exemption forbids.
// When a producer makes content reachable (ga-mt1e99) it owns the SchemaVersion
// decision; exporting this gate without that coordination must fail here rather
@@ -204,7 +263,7 @@ func TestOptionsContentOptInUnexported(t *testing.T) {
t.Fatal("Options.emitContent missing: the content opt-in gate must exist as an unexported field")
}
if f.PkgPath == "" {
- t.Fatal("Options.emitContent must stay UNEXPORTED: an exported content opt-in lets importers emit title/formula on schema v2 without a SchemaVersion bump (see ga-mt1e99)")
+ t.Fatal("Options.emitContent must stay UNEXPORTED: an exported content opt-in lets importers emit title/formula on schema v4 without a SchemaVersion bump (see ga-mt1e99)")
}
}
@@ -241,7 +300,7 @@ func TestProjectEvent_ContentGating(t *testing.T) {
t.Fatalf("formula must round-trip verbatim, got %q want %q", on.Formula, src.Formula)
}
if on.StepID != src.StepID {
- t.Fatalf("step_id must round-trip (opaque), got %q want %q", on.StepID, src.StepID)
+ t.Fatalf("step_id must round-trip, got %q want %q", on.StepID, src.StepID)
}
if err := ValidateEnvelope(on); err != nil {
t.Fatalf("populated content envelope must validate: %v", err)
diff --git a/release-gates/explicit-city-scope-pin-gate.md b/release-gates/explicit-city-scope-pin-gate.md
new file mode 100644
index 0000000000..dec760884a
--- /dev/null
+++ b/release-gates/explicit-city-scope-pin-gate.md
@@ -0,0 +1,29 @@
+# Release gate: explicit formula city-scope pin
+
+- Deploy bead: `ga-vcj2vo`
+- Build bead: `ga-61cxkw`
+- Review bead: `ga-4qlsxg`
+- Reviewed source: `e25f6e9df1a7b50059c11a0448a12c24aae00b4a`
+- Gate base: `origin/main@e6135a435098a70f20081d1d88a03b6742002d9a`
+- Evaluation date: 2026-07-30
+- Disposition: **PASS**
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Independent review bead `ga-4qlsxg` records `verdict: pass` for reviewed source `e25f6e9df1a7b50059c11a0448a12c24aae00b4a`. |
+| 2 | Acceptance criteria met | **PASS** | `resolveFormulaScope` and `rigFormulaVarsForScope` both honor explicit `--city` after explicit `--rig` and before ambient `GC_RIG` or cwd discovery. Focused tests cover city-over-`GC_RIG`, city-over-cwd, `GC_RIG`-over-cwd, and unbound-`GC_RIG` fallthrough with the selected-rig warning. |
+| 3 | Tests pass | **PASS** | At the reviewed source SHA, `go build ./...` and `go vet ./...` passed. `go test ./cmd/gc/ -run 'TestResolveFormulaScope\|TestRigFormulaVarsForScope' -count=1 -v` reported 14 PASS, 0 FAIL, 0 SKIP. `make test-fast-parallel` passed all 10 jobs. The required `make test-cmd-gc-process-parallel` coverage passed all six `GC_FAST_UNIT=0` shards plus `productmetrics-testhook`, reporting 15,247 PASS, 0 FAIL, and 11 intentional skips; `TestTutorial01` ran and passed. The skips are existing helper-only, opt-in live-canary, unsupported-OS, unavailable optional prompt-fixture, or ambient-cwd cases explicitly disabled inside test binaries; none bears on formula scope precedence. |
+| 4 | No high-severity review findings open | **PASS** | The independent review reports no security, style, specification, blocker, or major findings; unresolved HIGH count is 0. |
+| 5 | Final branch is clean | **PASS** | `git status --porcelain` was empty after testing at the reviewed source SHA; only these gate-record edits were then added. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after tests. `git merge-tree --write-tree origin/main e25f6e9df1a7b50059c11a0448a12c24aae00b4a` exited 0 against the gate base and produced tree `06495988b3b266e76e96f99fdac35647b81abc94`; no self-rebase was required. |
+| 7 | Single feature theme | **PASS** | The reviewed three-commit set changes `cmd/gc` formula scope resolution, its tests, and the corresponding gate evidence only. It restores one precedence rule: explicit `--city` pins city scope ahead of ambient rig discovery. |
+
+## Acceptance evidence
+
+- Explicit `--rig` remains the highest-priority scope selector.
+- Explicit `--city` now pins formula operations to city storage and city formula layers ahead of `GC_RIG` and cwd-based rig discovery.
+- City scope supplies no rig-scoped formula variables.
+- Existing `GC_RIG` precedence and unbound-rig fallthrough behavior remain covered.
+- No configuration schema, API wire shape, migration, dependency, or unrelated subsystem changes.
diff --git a/release-gates/ga-0yb884-pending-create-manager-clock-gate.md b/release-gates/ga-0yb884-pending-create-manager-clock-gate.md
new file mode 100644
index 0000000000..80587790bd
--- /dev/null
+++ b/release-gates/ga-0yb884-pending-create-manager-clock-gate.md
@@ -0,0 +1,58 @@
+# Release Gate: pending-create timestamps use the manager clock
+
+- Deploy bead: `ga-0yb884`
+- Review bead: `ga-g8n6ot`
+- Reviewed source commit: `d19b9e51eb51aad0b924766804dbd7cc6677bae9`
+- Base checked: `origin/main` at `b677c58ac3628d70636fa7ad58286cc7d8074df8`
+
+`docs/PROJECT_MANIFEST.md` is not present in this checkout. This checklist
+applies the release criteria supplied in the deployer instructions and the
+repository's documented test targets.
+
+## Checklist
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | `ga-g8n6ot` is closed with reason `pass`; its notes record `REVIEWER VERDICT: PASS` for the exact reviewed source commit. |
+| 2 | Acceptance criteria met | PASS | `createBeadOnly` now stamps `pending_create_started_at` using the manager clock in UTC; the existing nil-safe production fallback remains the real clock. The session chaos harness supplies its fake clock, the rollback tests no longer rewrite timestamp metadata manually, and the lease-expiry test releases at fake-clock tick 12, beyond the 10-minute floor. |
+| 3 | Tests pass | PASS | `LOCAL_TEST_JOBS=2 make test-local-full-parallel` completed 40 runner jobs: **40 PASS, 0 FAIL, 0 SKIP**. The controlled local toolchain used the repository-compatible `bd` 1.1.0, Dolt 2.1.7, and tmux 3.4 while retaining the real city home. Four named clock/rollback tests passed with **4 PASS, 0 FAIL, 0 SKIP**. `go build ./...` and `go vet ./...` both exited 0. |
+| 4 | No high-severity review findings open | PASS | The reviewer reported no blockers and no OWASP concerns; unresolved HIGH finding count is 0. |
+| 5 | Final branch is clean | PASS | Before writing this gate, `git status --porcelain=v1` produced no output and `git diff --check` exited 0. The gate file is the only deployer-added change and will be committed before push. |
+| 6 | Branch diverges cleanly from main | PASS | Evaluated first and rechecked after the test run. `git merge-tree --write-tree origin/main d19b9e51eb51aad0b924766804dbd7cc6677bae9` exited 0 against the current base and produced tree `d353717df2396a2c379bf6994edfa73e42b93957`; no self-rebase was needed. |
+| 7 | Single feature theme | PASS | The two reviewed commits touch four files in `internal/session` and `cmd/gc` for one behavior: sourcing pending-create timestamps and their rollback tests from the manager clock. |
+
+## Test Evidence
+
+```text
+LOCAL_TEST_JOBS=2 make test-local-full-parallel
+40 PASS, 0 FAIL, 0 SKIP
+
+go test ./internal/session/... -run TestCreateSessionBeadOnlyStampsPendingCreateStartedAtFromManagerClock -json
+1 named test PASS, 0 FAIL, 0 SKIP
+
+go test ./cmd/gc/... -run 'TestDesiredPendingCreateRollsBackWhenStartKeepsFailing|TestDesiredQuarantinedPendingCreateRollsBackAfterLeaseExpiry|TestDesiredCreatingPendingCreateReleasesClaim' -json
+3 named tests PASS, 0 FAIL, 0 SKIP
+
+go build ./...
+PASS
+
+go vet ./...
+PASS
+```
+
+The full runner's zero-skip result needs no skip exception. Earlier diagnostic
+runs exposed host-tool mismatches and local Dolt bootstrap contention; they
+were not counted as release evidence. The final audited run used pinned,
+repository-compatible tools and two-way local concurrency and completed
+without failures or skips.
+
+## Scope Evidence
+
+```text
+cmd/gc/session_lifecycle_chaos_test.go
+cmd/gc/session_pending_create_rollback_desired_test.go
+internal/session/manager.go
+internal/session/manager_test.go
+
+4 files changed, 54 insertions(+), 27 deletions(-)
+```
diff --git a/release-gates/ga-313wyg-portable-child-leak-detection-gate.md b/release-gates/ga-313wyg-portable-child-leak-detection-gate.md
new file mode 100644
index 0000000000..329e74e66d
--- /dev/null
+++ b/release-gates/ga-313wyg-portable-child-leak-detection-gate.md
@@ -0,0 +1,38 @@
+# Release gate: portable child-process leak detection
+
+- Deploy/review bead: `ga-313wyg`
+- Build bead: `ga-gxmz9n`
+- Reviewed source: `2df8be32fb7090172b86e6d3afb82d3cdd32ebdf`
+- Deploy branch: `deploy/ga-313wyg-gate`
+- Gate base: `origin/main@c0f633d2c18d17ca8dcd7f99d553127cb9ce0483`
+- Evaluation date: 2026-07-30
+- Disposition: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present at the reviewed commit, so this
+checklist applies the deployer role's release-gate criteria and the test
+evidence requirements in
+`engdocs/contributors/release-gate-criteria-conventions.md`.
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first and again after testing. `git merge-tree --write-tree origin/main 2df8be32fb7090172b86e6d3afb82d3cdd32ebdf` exited 0 against `origin/main@c0f633d2c18d17ca8dcd7f99d553127cb9ce0483` and produced tree `bf089bf9adf7f33726b5e25b0d95c0fa0a318a8d`. No self-rebase or source-branch mutation was required. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-313wyg` records `verdict: pass` for the three-commit reviewed tip, including the mandatory resource-census update. |
+| 2 | Acceptance criteria met | **PASS** | `pidutil.ChildPIDs` now enumerates direct children through bounded `ps -axo pid=,ppid=` execution on Linux and macOS, excludes the enumeration helper's own PID, and returns enumeration errors. The workspace test leak guard delegates to that helper and fails closed when enumeration is unavailable instead of reporting a clean run. Tests cover a live child, helper-PID exclusion, a hung `ps`, clean/leaked/unavailable decisions, and the surviving-child regression. The production orphan-reaping path is unchanged, no external dependency was added, and the source-resource ledger acknowledges the new subprocess and fixed-sleep sites. |
+| 3 | Tests pass | **PASS** | `go build ./...`, `go vet ./...`, changed/affected lint (0 issues), changed-file formatting, and `git diff --check` passed. The focused JSON run over `internal/pidutil`, `internal/workspacesvc`, and `internal/testpolicy/resourcecensus` recorded **292 PASS, 0 FAIL, 8 SKIP**. The eight skips are six existing host-subreaper cases plus the two standard self-exec helper/harness entry points; none exercises `ChildPIDs`, `livingTestChildren`, or `shouldFailForLeak`. The documented `make test-local-full-parallel` selected 40 jobs and initially recorded 35 PASS/5 environment failures. Those red results were not counted as passes: three were rerun successfully with CI's released `bd v1.1.0` binary (core package shard 4, formula recovery, and REST-full shard 7), and two unchanged tmux shards were rerun successfully with isolated tmux 3.4, matching Ubuntu CI rather than this Fedora host's tmux 3.7b default-binding behavior. Final CI-matched census: **40 PASS, 0 unresolved FAIL**. The hook-enforced `make test-fast-parallel` added **10 PASS, 0 FAIL** jobs. Preflight policy/boundary/native-DoltLite/docs checks, Tier A acceptance, the bd CLI contract, and Darwin/arm64 cross-compilation of both changed packages also passed. Generated/dashboard/release-config jobs were not locally repeated because this diff touches none of their inputs; GitHub required CI remains authoritative before merge. |
+| 4 | No high-severity review findings open | **PASS** | The independent review reports no security, style, or specification findings and no uncovered acceptance criteria. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | `git status --porcelain` was empty after all tests and test-created schema cleanup. The configured hook path is `.githooks`; this checklist is the only deployer-authored release change. |
+| 7 | Single feature theme | **PASS** | The three-commit set changes one portable child-process enumeration and leak-detection path, its regression tests, and the mechanically required resource-census baselines. No independent feature is bundled. |
+
+## Acceptance evidence
+
+- Direct-child enumeration no longer depends on `/proc`, so the macOS test
+ leak guard performs a real check.
+- Enumeration failure is distinguishable from a clean result and fails the
+ package run.
+- The `ps` helper is bounded to one second and cannot count itself as a leaked
+ child.
+- The existing production orphan-reaping behavior remains unchanged.
+- No API, configuration, persistence migration, or external dependency is
+ introduced.
diff --git a/release-gates/ga-4vctmi-digit-leading-dolt-database-name-gate.md b/release-gates/ga-4vctmi-digit-leading-dolt-database-name-gate.md
new file mode 100644
index 0000000000..114eb00e8a
--- /dev/null
+++ b/release-gates/ga-4vctmi-digit-leading-dolt-database-name-gate.md
@@ -0,0 +1,63 @@
+# Release gate: digit-leading Dolt database names
+
+- Deploy bead: `ga-4vctmi`
+- Source bead: `ga-p658sc`
+- Reviewed source: `adbf5fed223ef1f707f9c27799a251cbe091da10`
+- Gate base: `origin/main@6fd8f97c4042bcbf37b734278ef4df24035f5436`
+- Evaluation date: 2026-07-31
+- Disposition: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this repository at the evaluated
+commit. This checklist applies the deployer role's release criteria and the
+repository's documented CI-equivalent test policy.
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | The deploy bead records reviewer PASS for the exact source SHA. The source bead's notes contain `REVIEWER VERDICT: PASS` after an independent build, vet, focused-unit, acceptance, scope, compatibility, and security review. |
+| 2 | Acceptance criteria met | **PASS** | Non-HQ default Dolt database names derived from digit-leading rig prefixes now receive an `r` prefix at the single prefix-to-database boundary. HQ remains `hq`; ordinary letter-led prefixes and digits after the first character remain unchanged. The focused test passed 5 subtests, and `TestRegression_GastownWithRigs` passed both end-to-end subtests. The rig's display name, bead prefix, `DeriveBeadsPrefix`, configuration serialization, and existing metadata override precedence are unchanged. |
+| 3 | Tests pass | **PASS** | The authoritative GitHub CI run for exact head `adbf5fed223ef1f707f9c27799a251cbe091da10` ([run 30608984961](https://github.com/gastownhall/gascity/actions/runs/30608984961)) completed with **44 jobs PASS, 0 FAIL, 14 SKIP**, including `CI / required`, preflight static, acceptance A, all 12 non-short `cmd/gc` process shards, product-metrics testhook, all path-required package/tmux/bdstore/REST-smoke integration lanes, and worker phase 2. The 14 skips are intentional push-only, unrelated-path, unsupported-OS, or optional live-contract lanes; none owns this change. Locally, `go build ./...` and `go vet ./...` passed; the focused unit owner passed **5 PASS, 0 FAIL, 0 SKIP**, and the acceptance owner passed **2 PASS, 0 FAIL, 0 SKIP**. A 40-job local diagnostic retained **36 PASS, 4 FAIL, 0 SKIP**: all four failures were push-only REST-full jobs contaminated by stale Dolt processes from earlier interrupted diagnostics, with three reporting explicit foreign-PID port collisions; these jobs are not part of the PR-required graph and the exact-sha CI run is the authoritative clean execution. |
+| 4 | No high-severity review findings open | **PASS** | Reviewer notes report no blocking, security, compatibility, or scope findings. Unresolved HIGH/CRITICAL finding count: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this gate, `git status --porcelain=v1 --untracked-files=no` produced no output and `git diff --check origin/main...adbf5fed223ef1f707f9c27799a251cbe091da10` exited 0. The only untracked paths are provider-materialized skill metadata under `.claude/skills/`; they are not staged or part of the deploy branch. This gate file is the sole deployer-authored change and will be committed before push. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after tests. `git merge-tree --write-tree origin/main adbf5fed223ef1f707f9c27799a251cbe091da10` exited 0 against current `origin/main@6fd8f97c4042bcbf37b734278ef4df24035f5436` and produced tree `0a2187801f8c3c2ff4cfa10fbfe25f0527199079`. The source is two commits ahead and two behind current main with no content conflict; no self-rebase was needed. |
+| 7 | Single feature theme | **PASS** | The two-commit TDD diff changes only `cmd/gc/beads_provider_lifecycle.go` and its adjacent test. Both commits address one behavior: making default Dolt database identifiers valid when a rig-derived prefix starts with a digit. |
+
+## Test evidence
+
+```text
+GitHub CI run 30608984961 at adbf5fed223ef1f707f9c27799a251cbe091da10
+44 jobs PASS, 0 FAIL, 14 SKIP
+CI / required: PASS
+
+go build ./...
+PASS
+
+go vet ./...
+PASS
+
+PATH= go test -count=1 -v ./cmd/gc \
+ -run '^TestDefaultScopeDoltDatabase$'
+5 subtests PASS, 0 FAIL, 0 SKIP
+
+PATH= go test -tags acceptance_a -count=1 -v \
+ ./test/acceptance/... -run '^TestRegression_GastownWithRigs$'
+2 subtests PASS, 0 FAIL, 0 SKIP
+```
+
+The CI-matched local tool bundle used the repository-pinned `bd` 1.1.0
+release build, Dolt 2.1.7, and tmux 3.4. The first two local diagnostics
+identified host-tool drift (`bd` reported the same version from a different
+build, Dolt was 2.2.1, and tmux was 3.7b); those results were not counted as
+release evidence. The later REST-full failures were retained rather than
+retried into green and are classified as runner contamination because they
+name stale, foreign-project Dolt PIDs occupying newly selected ports. The
+exact-sha required CI run is clean.
+
+## Scope evidence
+
+```text
+cmd/gc/beads_provider_lifecycle.go | 13 ++++++++++++-
+cmd/gc/beads_provider_lifecycle_test.go | 60 ++++++++++++++++++++++++++++++++
+2 files changed, 72 insertions(+), 1 deletion(-)
+```
diff --git a/release-gates/ga-65i89y-cmd-gc-evalsymlinks-migration-gate.md b/release-gates/ga-65i89y-cmd-gc-evalsymlinks-migration-gate.md
new file mode 100644
index 0000000000..f2ddfc43a2
--- /dev/null
+++ b/release-gates/ga-65i89y-cmd-gc-evalsymlinks-migration-gate.md
@@ -0,0 +1,33 @@
+# Release gate: classify and migrate bare EvalSymlinks in the cmd/gc CLI cluster
+
+- Deploy bead: `ga-65i89y`
+- Build bead: `ga-iawy13.3`
+- Review bead: `ga-xaed29`
+- Reviewed commit: `294c27a69308d3bc18451aae222a279774dccbe0`
+- Gate base: `origin/main` at `0223c3af63cf5cab296f9abed25bcced5eb91794`
+- Evaluated: 2026-08-03
+- Result: **PASS**
+
+Criterion 6 was evaluated first, as required. The remaining criteria were then
+evaluated in numeric order. `docs/PROJECT_MANIFEST.md` is absent from both the
+reviewed commit and current `origin/main`; this checklist therefore applies the
+deployer gate criteria and
+`engdocs/contributors/release-gate-criteria-conventions.md` directly.
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Review bead `ga-xaed29` is closed with reason `pass`. Its round-2 notes record `verdict: PASS`, `review_round: 2`, and explicitly pin the reviewed commit to `294c27a69308d3bc18451aae222a279774dccbe0` — the reviewer flagged that `bd` metadata still carried round-1's stale SHA and used the git-verified branch-tip SHA instead. |
+| 2 | Acceptance criteria met | **PASS** | Round-1 review found one `uncovered_criteria` gap: the `absPackRoot` (`cmd_registry.go:306`) and `repoRoot` (`cmd_registry.go:320`) normalization sites inside `buildRegistryPublishRequest` had no symlink-specific test. The round-2 diff (`bbf12c0199`..`294c27a693`, `cmd/gc/cmd_registry_test.go` `+27/-0`, test-only, no production code touched — confirmed via `git diff --stat`) adds `TestBuildRegistryPublishRequestResolvesSymlinkedPackRoot`; the reviewer independently read it against `buildRegistryPublishRequest` and confirmed it exercises both flagged sites in one scenario. All 8 `exit_contract` sites in the bead's own classification matrix are accounted for: 7 migrated to `pathutil`, 1 justified existence-only exception (`controller.go:626`, carries the `canonical-path-exception` comment as claimed). Round-1's `uncovered_criteria` finding is explicitly marked closed in the round-2 notes. |
+| 3 | Tests pass | **PASS** | Required target `make test-cmd-gc-process-parallel` (`GC_FAST_UNIT=0`) was run against the reviewed commit `294c27a69308d3bc18451aae222a279774dccbe0` in isolated worktree `worktrees/ga-iawy13.3` (working tree clean, `HEAD` confirmed at the reviewed SHA). Result: all 6 shards + `productmetrics-testhook` reported `ok`/`pass`; `grep -E '^--- FAIL|^FAIL[[:space:]]'` across all 7 shard logs returned 0 matches; driver output `All cmd-gc-process jobs passed`, exit 0. This independently corroborates the reviewer's own round-2 evidence at the identical SHA (8243 tests across 6 shards `1374/1374/1374/1374/1374/1373` + 6 `productmetrics-testhook` tests, 0 failures, 0 skips). For the record: two earlier deploy-gate evaluation cycles on this same bead (see bead notes) hit exactly 3 failures at this identical, unchanged SHA — `TestBuildDesiredState_MinZeroDefaultScaleCheckRoutedWorkCreatesPoolSession`, `TestEvaluatePoolDefaultScaleCheckCountsRoutedReadyWork`, `TestEvaluatePoolDefaultScaleCheckIgnoresRoutedActiveUnassignedWork` — the same known ambient shared-Dolt-server signature root-caused at `ga-zxpfic` (closed) and previously precedented at gates `ga-pfdabs`/`ga-vn396k` against this exact 3-test signature. Two independent clean runs (the reviewer's and this gate's) and two independent failed runs all occurred at the same unchanged commit, which is itself direct evidence the failures are nondeterministic ambient-environment contention rather than anything introduced by this change. This gate's own run was unconditionally clean, so no merge-base differential was required to establish non-regression. The scoped environment fix remains tracked by open bead `ga-us7c35` (P1, unmerged). Logs: `/var/tmp/gc-ga-65i89y-gate/reviewed/*.log`. |
+| 4 | No high-severity review findings open | **PASS** | Round-2 notes: `style_findings` clean (`gofmt -l` 0 files, `go vet ./...` exit 0 / 0 output); `security_findings` — no production code changed this round, round-1's OWASP walk and A01 fail-open-to-fail-closed analysis (`doctorPathWithinCity`) stands unchanged, no blockers; round-1's sole substantive finding (`uncovered_criteria`) explicitly closed. Notes conclude "No blockers remain." |
+| 5 | Final branch is clean | **PASS** | `git status` in isolated worktree `worktrees/ga-iawy13.3` at `HEAD` `294c27a69308d3bc18451aae222a279774dccbe0`: "nothing to commit, working tree clean." |
+| 6 | Branch diverges cleanly from main | **PASS** | After `git fetch origin main` (tip `0223c3af63cf5cab296f9abed25bcced5eb91794`), `git merge-tree --write-tree origin/main 294c27a69308d3bc18451aae222a279774dccbe0` exited 0 and produced tree `25087a7416ae5ea763c8ca08f14546c6e2928e24`; no content conflict, no self-rebase required. |
+| 7 | Single feature theme | **PASS** | The 3-commit TDD sequence (red `7fed162ed4a3ff80b0cfc23f4ca79b2f6e71acf3`, green `bbf12c0199f60e8b0462dca088754f74e22a895e`, round-2 fix `294c27a69308d3bc18451aae222a279774dccbe0`) touches exactly 10 files, all under `cmd/gc/`: `cmd_import.go`, `cmd_pack_release.go`(+test), `cmd_registry.go`(+test), `cmd_supervisor_city.go`(+test), `controller.go`, `doctor_v2_checks.go`(+test) — all within the single declared theme of classifying and migrating bare `filepath.EvalSymlinks` calls to `pathutil` in the `cmd/gc` CLI cluster. |
+
+## Gate decision
+
+The reviewed change introduces no process-suite regression relative to its
+merge-base (this run was unconditionally clean), satisfies the round-2
+acceptance-criteria fix confirmed by direct reviewer read, and remains
+conflict-free with current `origin/main`. It is eligible for an isolated
+deploy branch and pull request.
diff --git a/release-gates/ga-7vhfyj-cwd-fallback-guard-gate.md b/release-gates/ga-7vhfyj-cwd-fallback-guard-gate.md
new file mode 100644
index 0000000000..6b11c109f9
--- /dev/null
+++ b/release-gates/ga-7vhfyj-cwd-fallback-guard-gate.md
@@ -0,0 +1,105 @@
+# Release gate: non-interactive cwd fallback guard
+
+**Deploy bead:** `ga-7vhfyj`
+**Build bead:** `ga-81d3x5`
+**Review bead:** `ga-hrc5gx`
+**Reviewed commit:** `02b568c035d308eb40c31123430aa9a20f0fb419`
+**Base checked:** `origin/main` at `af42a94245a547a0c47ec26054afa5fd1347b567`
+**Isolated branch:** `deploy/ga-7vhfyj-gate`
+**Verdict:** **PASS**
+
+See "Post-gate amendment" below: criteria 2 and 3 are corrected.
+
+`docs/PROJECT_MANIFEST.md` is absent from both the reviewed commit and current
+`origin/main`, so there are no additional repository-local release criteria to
+apply beyond the seven deployer gate criteria below.
+
+## Gate criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | Review bead `ga-hrc5gx` contains `REVIEW VERDICT: PASS`, is closed with reason `pass`, and records independent review at `02b568c035d308eb40c31123430aa9a20f0fb419`. Reviewer mail `gm-wisp-mij4nfi` confirms the deploy handoff. |
+| 2 | Acceptance criteria met | PASS | `resolveImplicitCWD` uses `term.IsTerminal` and fails closed for non-interactive stdin. All five implicit-path call sites across `gc init` and `gc start` route through it; no bare `os.Getwd()` remains in `cmd_init.go` or `cmd_start.go`. The targeted test matrix passes. Compiled-binary smoke confirms no-argument `gc init` with `/dev/null` or piped stdin and no-argument `gc start` all refuse with exit 1 before creating a city, while explicit-path `gc init --no-start` succeeds. |
+| 3 | Tests pass | PASS | `go build ./...` passes in 20.63s; `go vet ./...` passes in 17.48s; targeted guard tests pass in 3.606s; `make test-fast-parallel` passes all 9 jobs in 193.65s; `make lint-new` reports 0 issues. The reviewer independently ran the full `cmd/gc` package: 8,030 PASS, 0 FAIL, 96 SKIP in 343.911s. |
+| 4 | No high-severity review findings open | PASS | Zero unresolved HIGH findings. The only reviewer observation is the non-blocking, pre-existing wizard-trigger use of `isTerminalFunc`, explicitly outside this bead's scope. |
+| 5 | Final branch is clean | PASS | The reviewed tree was clean before gate creation; after committing this checklist on the isolated deploy branch, `git status --porcelain` is empty. |
+| 6 | Branch diverges cleanly from main | PASS | Checked first. `git merge-tree --write-tree origin/main 02b568c035d308eb40c31123430aa9a20f0fb419` succeeded with tree `578a714c6962d3fca18d7a19cdcbbd759891e61a`. The reviewed history is two commits behind and two ahead, with no conflicts; no bounded self-rebase was needed. |
+| 7 | Single feature theme | PASS | Both reviewed commits are the RED/GREEN pair for one `cmd/gc` behavior: refusing unsafe implicit-current-directory fallback under non-interactive stdin. The small internal parameter cleanup in `cmdInitWithOptions` removes newly exposed dead parameters in the same call path and is not an independent feature. |
+
+## Reviewed history
+
+```text
+9262373ab test(cmd/gc): red — refuse implicit cwd fallback on non-tty stdin
+02b568c03 feat: green — refuse implicit cwd fallback on non-tty stdin
+```
+
+The commit set touches seven files under `cmd/gc`: two command implementations,
+the new shared guard and its tests, and three affected test call sites. It does
+not change configuration, HTTP/API schemas, generated assets, or dashboard
+code.
+
+## Test evidence
+
+```text
+go test ./cmd/gc \
+ -run '^(TestResolveImplicitCWD_|TestCmdInit_NoArgs|TestCmdInit_ExplicitPath|TestCmdInitFromFile_NoArgs|TestCmdInitFromDir_NoArgs|TestResolveStartDir_)' \
+ -count=1
+ok github.com/gastownhall/gascity/cmd/gc 3.606s
+
+go build ./...
+PASS (20.63s)
+
+go vet ./...
+PASS (17.48s)
+
+make test-fast-parallel
+All fast jobs passed (9/9, 193.65s)
+
+make lint-new
+0 issues
+```
+
+Compiled-binary smoke:
+
+```text
+gc init exit 1, explicit non-interactive error
+printf ... | gc init -> exit 1, explicit non-interactive error
+gc start exit 1, explicit non-interactive error
+gc init --no-start exit 0, city.toml created in scratch path
+```
+
+## Post-gate amendment — guard narrowed to gc init (ga-w3rhto)
+
+CI on PR #4738 failed after this gate recorded PASS. `cmd/gc process / shard 7
+of 12` failed `TestTutorial01/01-hello-gas-city` and `TestTutorial01/session-fail`,
+both at a bare `exec gc start`. Reproduced locally on the gate branch and
+confirmed green on `origin/main`, so it is a regression from this change, not a
+flake.
+
+**Correction to criterion 2.** Applying the guard to `gc start` was not
+required by the stated hazard and is now reverted. `resolveStartDir` feeds
+`requireBootstrappedCity` (`cmd/gc/cmd_start.go`), which resolves through
+`findCity` — an upward walk for an existing `city.toml`/`.gc` — and returns an
+error *before any side effect* when there is none. `gc start` therefore cannot
+bootstrap or leak state in an arbitrary checkout; only `gc init` can. The guard
+now covers the three `gc init` implicit-path branches only, and criterion 2's
+"no bare `os.Getwd()` remains in `cmd_start.go`" no longer holds by design.
+
+The guard on `gc start` also reached two commands outside the stated scope:
+`gc restart` (via the shared `restartTarget` → `resolveStartDir`) and
+`gc start --foreground`, the documented foreground/container controller entry
+point. Neither is mentioned in the PR description.
+
+**Gap in criterion 3.** Every suite cited under criterion 3 is structurally
+unable to reach the failing tests. `TestTutorial01` is gated by
+`skipSlowCmdGCTest`, which skips unless `GC_FAST_UNIT=0`
+(`cmd/gc/fast_loop_helpers_test.go:17`). `make test-fast-parallel` sets
+`GC_FAST_UNIT=1`, and a bare `go test ./cmd/gc` leaves it unset — so the
+reviewer's "8,030 PASS, 0 FAIL, 96 SKIP" full-package run skipped these
+scenarios rather than passing them. Only `make test-cmd-gc-process`
+(`GC_FAST_UNIT=0`) runs them. A change to a command's path-resolution behavior
+should be gated on a suite that executes the CLI end to end.
+
+**Verification after narrowing:** `TestTutorial01` (full) passes; all `gc init`
+guard tests still pass unchanged.
diff --git a/release-gates/ga-94bs0w-canonical-path-convergence-dispatch-gate.md b/release-gates/ga-94bs0w-canonical-path-convergence-dispatch-gate.md
new file mode 100644
index 0000000000..80c1787ab0
--- /dev/null
+++ b/release-gates/ga-94bs0w-canonical-path-convergence-dispatch-gate.md
@@ -0,0 +1,48 @@
+# Release gate: canonical path classification in convergence and dispatch
+
+- Deploy bead: `ga-94bs0w`
+- Build bead: `ga-iawy13.4`
+- Review bead: `ga-72lu2m`
+- Reviewed source: `e8b75defefb74c6844a19a722cebdbd54dbe470a`
+- Deploy branch: `deploy/ga-94bs0w-gate`
+- Gate base: `origin/main@0223c3af63cf5cab296f9abed25bcced5eb91794`
+- Evaluation date: 2026-08-03
+- Disposition: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present at the reviewed commit, so this
+checklist applies the deployer role's release criteria and the repository's
+documented test-evidence policy.
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after testing. `git merge-tree --write-tree origin/main e8b75defefb74c6844a19a722cebdbd54dbe470a` exited 0 against `origin/main@0223c3af63cf5cab296f9abed25bcced5eb91794` and produced tree `fe1ab02e397d97fade37998bea4085db92d1702d`. The source is two commits ahead and one behind current main with no content conflict; no self-rebase or source-branch mutation was needed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-72lu2m` is closed with reason `pass`, records `verdict: pass`, and names the exact reviewed source SHA. The reviewer independently verified the classification, tests, formatting, and path-containment security behavior. |
+| 2 | Acceptance criteria met | **PASS** | All nine scoped `filepath.EvalSymlinks` sites are classified in the matrix below. The three comparison-preparation inputs use `pathutil.NormalizePathForCompare` at subsystem entry; the six existence/resolvability checks remain bare with adjacent `canonical-path-exception` justification. New tests cover relative and symlinked spellings, missing paths, contained targets, and symlink escapes. The focused package suite and vet pass, and no scoped production call remains unexplained. |
+| 3 | Tests pass | **PASS** | At the exact reviewed SHA, documented `make test-fast-parallel` completed **10 PASS jobs, 0 FAIL jobs, 0 SKIP jobs**. `go build ./...` and `go vet ./...` exited 0. A fresh JSON run of `go test -count=1 ./internal/convergence/... ./internal/dispatch/...` recorded **738 PASS, 0 FAIL, 0 SKIP**. `git diff --check origin/main...HEAD` also passed. |
+| 4 | No high-severity review findings open | **PASS** | Reviewer notes report no specification, style, security, compatibility, or uncovered-criteria blockers. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this checklist, `git status --porcelain=v1 --untracked-files=all` produced no output. The configured hook path is `.githooks`; this checklist is the sole deployer-authored release change and will be committed before push. |
+| 7 | Single feature theme | **PASS** | The two-commit TDD set changes one canonical-path-at-ingest behavior across the coupled convergence and dispatch path-validation surfaces. All eight changed files are implementation or adjacent tests for that theme; no independent feature is bundled. |
+
+## Per-site classification
+
+| Site | Behavior class | Disposition |
+|---|---|---|
+| `internal/convergence/artifact.go` — artifact root | Existence/resolvability | Keep `EvalSymlinks`; a missing or unresolvable artifact directory must fail. |
+| `internal/convergence/artifact.go` — walked symlink target | Existence/resolvability | Keep `EvalSymlinks`; a dangling or unresolvable target must fail validation. |
+| `internal/convergence/condition.go` — envelope | Comparison preparation | Normalize once with `pathutil.NormalizePathForCompare`. |
+| `internal/convergence/condition.go` — base | Comparison preparation | Normalize once with `pathutil.NormalizePathForCompare`. |
+| `internal/convergence/condition.go` — condition script | Existence/resolvability | Keep `EvalSymlinks`; the script must resolve to an executable file. |
+| `internal/convergence/evaluate.go` — city path | Comparison preparation | Normalize once with `pathutil.NormalizePathForCompare`. |
+| `internal/convergence/evaluate.go` — prompt path | Existence/resolvability | Keep `EvalSymlinks`; preserve the explicit symlink-presence check and deferred missing-file behavior. |
+| `internal/dispatch/retry.go` — worktree root | Existence/resolvability | Keep `EvalSymlinks`; fail closed if the worktree root does not resolve. |
+| `internal/dispatch/retry.go` — required artifact target | Existence/resolvability | Keep `EvalSymlinks`; preserve missing-target tolerance while rejecting a resolved target outside the worktree. |
+
+## Acceptance evidence
+
+- `TestResolveConditionPath/relative_envelope_combined_with_a_symlinked_conditionPath_segment_must_not_be_falsely_rejected` proves relative and symlinked spellings converge on the same containment decision.
+- `TestResolveEvaluateStep_RelativeCityPathReturnsAbsolutePromptPath` proves a relative city path produces a canonical absolute prompt path.
+- `TestValidateArtifactDir_MissingDir` preserves the artifact-root existence failure.
+- `TestRequiredArtifactTargetInWorktree` covers a missing target, a symlinked worktree root with a contained target, and a symlink escape outside the worktree.
+- No API, configuration, persistence, generated-schema, or dependency change is included.
diff --git a/release-gates/ga-9sp6gf-held-work-dispatch-gate.md b/release-gates/ga-9sp6gf-held-work-dispatch-gate.md
new file mode 100644
index 0000000000..942fbc1b82
--- /dev/null
+++ b/release-gates/ga-9sp6gf-held-work-dispatch-gate.md
@@ -0,0 +1,52 @@
+# Release Gate: held work automatic-dispatch suppression
+
+- Deploy bead: `ga-9sp6gf`
+- Review bead: `ga-sijivh`
+- Source bead: `ga-x9kptu`
+- Reviewed commit: `ff03c7d6d2cc48693a72e4e198e9c8f276abfecc`
+- Deploy branch: `deploy/ga-9sp6gf-gate`
+- Source branch: `builder/ga-x9kptu` (provenance only; not a deploy push target)
+- Base checked: `origin/main@85e3e5022b925c9781fb64e0b1a043133770cf72`
+- Release criteria source: `docs/PROJECT_MANIFEST.md` is not present in this checkout; this gate uses the active deployer release criteria and the repository testing policy in `TESTING.md`.
+
+## Summary
+
+PASS on 2026-08-03.
+
+The change prevents unassigned, route-scoped automatic dispatch from serving
+beads carrying either canonical hold label. Assignee-scoped recovery and ready
+queries remain hold-transparent, preserving deliberate assignment and recovery
+semantics.
+
+## Criterion 6: branch diverges cleanly from main
+
+PASS. Evaluated first.
+
+- `git merge-base --is-ancestor origin/main ff03c7d6d2cc48693a72e4e198e9c8f276abfecc` returned 0.
+- The merge base is `85e3e5022b925c9781fb64e0b1a043133770cf72`, the checked `origin/main` tip.
+- `git merge-tree --write-tree origin/main ff03c7d6d2cc48693a72e4e198e9c8f276abfecc` returned tree `5f4ce8c7db24335bda68dae6ed410c93c68c1c53` with exit 0.
+- No bounded self-rebase was needed.
+
+## Release criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | Review bead `ga-sijivh` records `verdict: pass` at the reviewed SHA after round 2 closed both previously uncovered criteria. |
+| 2 | Acceptance criteria met | PASS | Production entry-point coverage exercises control-ready cache evaluation and fallback filtering, route-scoped Tier-3 shell queries, legacy bd 1.0.4/1.0.5 query shapes, pool-demand count queries, and `buildOnBoot`/`buildOnDeath` recovery handoff. Tests preserve hold transparency for assignee-scoped Tier 1/2 paths, cover absent labels plus `hold:mayor`, `hold:external`, and both labels together, and derive enforcement from `beadmeta.DispatchHoldLabels`. RED commits `bd3449005` and `af24469ad` precede GREEN commits `82d01bb1b` and `ff03c7d6d`. Deterministic lifecycle tests use `t.TempDir()` and a local fake `bd`; the golden matrix covers supported bd semantics. |
+| 3 | Tests pass | PASS | `make test-fast-parallel`: 10/10 jobs passed. Counted run `go test -json ./cmd/gc/... ./internal/beadmeta/... ./internal/config/...`: 17,169 PASS, 0 FAIL, 104 SKIP. The skips are pre-existing OS/environment/slow-process tier gates; the focused hold-label and recovery run completed 25 PASS, 0 FAIL, 0 SKIP, so no feature test was skipped. `go vet ./...`, `go build ./...`, `git diff --check origin/main...HEAD`, and `gofmt -l` over changed Go files all passed. |
+| 4 | No high-severity review findings open | PASS | Review bead `ga-sijivh` records no security, style, or specification blocker and no unresolved HIGH finding. |
+| 5 | Final branch is clean | PASS | The isolated gate worktree was clean on `deploy/ga-9sp6gf-gate` at the exact reviewed SHA before this checklist was added. This gate file is the only deploy-only delta and will be committed separately. |
+| 6 | Branch diverges cleanly from main | PASS | See the criterion 6 evidence above. |
+| 7 | Single feature theme | PASS | All four commits and all touched packages implement one behavior: suppress held beads from ambient automatic dispatch while preserving deliberately assigned work. No independent feature is bundled. |
+
+## Test commands
+
+```bash
+make test-fast-parallel
+go test -json ./cmd/gc/... ./internal/beadmeta/... ./internal/config/...
+go test -json ./cmd/gc ./internal/beadmeta ./internal/config -run 'DispatchHoldLabels|HoldLabel|BuildOnDeathReopensHeldBead|BuildOnBootReopensHeldBead|WorkflowServeControlReadyQuery.*(Hold|ShellFallback)'
+go vet ./...
+go build ./...
+git diff --check origin/main...HEAD
+gofmt -l $(git diff --name-only origin/main...HEAD -- '*.go')
+```
diff --git a/release-gates/ga-anwmtr-gate.md b/release-gates/ga-anwmtr-gate.md
new file mode 100644
index 0000000000..8be7e22ee5
--- /dev/null
+++ b/release-gates/ga-anwmtr-gate.md
@@ -0,0 +1,71 @@
+# Release Gate: push-ownership-guard deploy-gate branch resolution fix
+
+- Deploy bead: `ga-anwmtr`
+- Source bead: `ga-wwswme`
+- Review bead: `ga-uq9095`
+- Reviewed commit: `b7e762eaf1eeaaca876d1c14dd63c45777d442ec`
+- Deploy branch: `deploy/ga-anwmtr-gate`
+- Evaluated: 2026-07-27
+- Gate source: deployer prompt release-gate table (matched against sibling
+ gates `release-gates/ga-hzy30q-push-ownership-guard-gate.md` and
+ `release-gates/ga-evd1s7-pre-push-ownership-guard-gate.md`, same script
+ family). `docs/PROJECT_MANIFEST.md` was not present in this checkout.
+
+## Summary
+
+PASS. Single-theme shell guard fix: `_pog_resolve_bead_id` in
+`scripts/push-ownership-guard.sh` prefers the live in-progress assignee over
+the closed gated bead when resolving a `deploy/*-gate` branch name, instead
+of trusting the branch-embedded bead ID (which is routinely already closed
+by push time -- that's the point of a deploy gate). Fixes a real cited
+incident (PR #4731 incorrectly blocked). Downgrades the resulting
+disagreement log line from WARNING to NOTE.
+
+An earlier attempt at this same gate (this bead, same reviewed commit) FAILED
+criterion 3 on `make test-fast-parallel`'s `unit-core` shard
+(`TestCachingStoreHandlesCachedListUsesActiveSnapshotAfterPrimeActive`,
+"cached active List did not return promptly from PrimeActive snapshot").
+That failure is retained here per TESTING.md rather than silently discarded:
+first-attempt gate record committed locally as `d70126efb` on a since-
+discarded `deploy/ga-anwmtr-gate` (never pushed); full first-run log at
+`/var/tmp/gc-local-tests.ZefBTS/unit-core.log` (that attempt's worktree, not
+this one). This retry cuts a fresh isolated worktree/branch off the same
+pinned reviewed commit and reruns the full gate from scratch, including a
+full (not just focused) `make test-fast-parallel` -- all 9 shards, including
+`unit-core`, pass clean this time. The diff under test (a bash script) has
+no code path into the Go caching-store snapshot logic the failing test
+exercises. Fleet memory `city-runtime-convergence-startup-flaky-under-shard-load`
+independently documents a recurring class of single-shard, unrelated-diff
+timing flakes under full `make test-fast-parallel` contention on this shared
+host (root-caused to `nice`/`ionice` deprioritization + uncapped GOMAXPROCS
+oversubscription across 6 concurrent shard processes, not a code defect).
+This is a single occurrence of a different test in that same general
+failure class, not (yet) independently confirmed recurring -- noted for
+visibility, not treated as fully closed.
+
+## Criteria
+
+| # | Criterion | Verdict | Evidence |
+|---|-----------|---------|----------|
+| 6 | Branch diverges cleanly from main | PASS | `git fetch origin main`; main had drifted 5 commits past this branch's merge-base (`af42a9424`) since cut, current tip `431711fe0`. `git merge-tree --write-tree origin/main b7e762eaf1eeaaca876d1c14dd63c45777d442ec` returned tree `fd7b636bbc4a88173ef0adf70992fb57aa7d75d0` (clean, no conflict markers); `git diff --check origin/main...b7e762eaf1eeaaca876d1c14dd63c45777d442ec` produced no output. |
+| 1 | Review PASS present | PASS | Review bead `ga-uq9095`, close reason `pass`. Notes contain `REVIEW VERDICT: PASS` and `tdd_green: b7e762eaf... — 28/28 tests pass (27 pre-existing + new deploy-gate regression test); go build/vet/gofmt/shellcheck all clean`, matching this gate's own independent rerun. |
+| 2 | Acceptance criteria met | PASS | Commit set is the expected red/green pair: `acd2e16b3` (test: red -- adds the failing deploy-gate-branch regression test) and `b7e762eaf1` (fix: green). Diff is limited to `scripts/push-ownership-guard.sh` (17 lines); no `cmd/gc` files touched. Guard suite includes the new regression `resolve/deploy-gate-branch-prefers-live-assignee` (live assignee `ga-mit0gh` used instead of closed gated bead `ga-g5ihlp`). |
+| 3 | Tests pass | PASS | `shellcheck scripts/push-ownership-guard.sh` clean. `go build ./...` clean. `go vet ./...` clean. `bash scripts/test-push-ownership-guard.sh` passed `28/28`, matching the reviewer's own evidence exactly. `make test-fast-parallel` passed all 9 fast jobs (fresh full run, not a focused single-test rerun -- see Summary for why a full rerun mattered here). |
+| 4 | No high-severity review findings open | PASS | `bd list --status open --limit 0 \| grep -iE 'ga-anwmtr\|ga-uq9095\|ga-wwswme'` returned only routine sling-tracking beads (`ga-2igi0a`, `ga-4td6gw`, `ga-lbnewn`, `ga-sc25lw`, all P2); no open HIGH/request-changes finding. |
+| 5 | Final branch is clean | PASS | Before adding this gate file, `git status --short --branch` on `deploy/ga-anwmtr-gate` returned only the branch header (worktree cut directly from the pinned reviewed commit, nothing else applied). This gate file is committed as the final branch tip before push. |
+| 7 | Single feature theme | PASS | The commit set touches one subsystem: `scripts/push-ownership-guard.sh` plus its test harness. Removing this fix would only affect deploy-gate branch-to-bead-ID resolution in the push ownership guard. |
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main b7e762eaf1eeaaca876d1c14dd63c45777d442ec
+git diff --check origin/main...b7e762eaf1eeaaca876d1c14dd63c45777d442ec
+git log --oneline -8 b7e762eaf1eeaaca876d1c14dd63c45777d442ec
+shellcheck scripts/push-ownership-guard.sh
+go build ./...
+go vet ./...
+bash scripts/test-push-ownership-guard.sh
+make test-fast-parallel
+bd list --status open --limit 0 | grep -iE 'ga-anwmtr|ga-uq9095|ga-wwswme'
+```
diff --git a/release-gates/ga-bgh2wi-lifecycle-worktree-provisioning-gate.md b/release-gates/ga-bgh2wi-lifecycle-worktree-provisioning-gate.md
new file mode 100644
index 0000000000..995e46f3ca
--- /dev/null
+++ b/release-gates/ga-bgh2wi-lifecycle-worktree-provisioning-gate.md
@@ -0,0 +1,48 @@
+# Release Gate: lifecycle worktree provisioning convergence
+
+Deploy bead: `ga-bgh2wi`
+Source bead: `ga-g8lt3x`
+Reviewed commit: `da9099c3c73609a9ecc45c796177cbf163ac8ff4`
+Reviewed commits: `31dc58d72`, `da9099c3c73609a9ecc45c796177cbf163ac8ff4`
+Planned deploy branch: `deploy/ga-bgh2wi-gate`
+Base: `origin/main` at `c31a67ea0fdbc13bff05b7a821cfead0d165dbc8`
+Gate evaluated: `2026-07-28`
+
+`docs/PROJECT_MANIFEST.md` is not present in this checkout, so this gate uses
+the deployer role's release criteria, the source bead's done-when criteria,
+and the repository test policy in `TESTING.md`.
+
+## Result
+
+PASS.
+
+## Criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 6 | Branch diverges cleanly from main | PASS | Evaluated first after `git fetch origin main`. `git merge-tree --write-tree origin/main da9099c3c73609a9ecc45c796177cbf163ac8ff4` exited 0 and produced merged tree `61de7fb992c0c890122e49eef7c5d0b7697408d9`. No self-rebase was needed. |
+| 1 | Review PASS present | PASS | `ga-bgh2wi` records `verdict: pass` for reviewed tip `da9099c3c73609a9ecc45c796177cbf163ac8ff4`; the reviewer independently checked the diff, reproduction, build, vet, style, security, and regression coverage. |
+| 2 | Acceptance criteria met | PASS | `ensure_worktree_provisioning` owns the bead redirect, submodule initialization, and local excludes; it is called from both the pre-existing-worktree early exit and fresh-create path, after the existence check. Tier A passed `TestLifecycleWorktreeSetupRedirectAppliesToPreExistingWorktree` and the fresh-create control `TestLifecycleWorktreeSetupBeadRedirect`. The related worktree tests also passed. |
+| 3 | Tests pass | PASS | `go build ./...` and `go vet ./...` passed. Documented `make test` passed with `34,129 PASS / 0 FAIL / 169 SKIP` tests (`163 PASS / 0 FAIL / 18 SKIP` packages). Documented per-PR Tier A `make test-acceptance` passed all 6 packages; structured replay recorded `344 PASS / 0 FAIL / 8 SKIP` tests. The fast-unit skips are the repository's documented process/integration/build-tag exclusions. Tier A's eight skips are seven explicit pending self-host UX tests and one opt-in live pack-registry smoke requiring `GC_TEST_GASCITY_PACKS_REGISTRY`; none touches the lifecycle worktree script or its tests. |
+| 4 | No high-severity review findings open | PASS | Reviewer notes report no style or security findings and no uncovered acceptance criteria; no HIGH finding remains open. |
+| 5 | Final branch is clean | PASS | The detached reviewed commit was clean before this checklist was added, and `git diff --check origin/main...da9099c3c73609a9ecc45c796177cbf163ac8ff4` passed. The deploy branch will contain only the reviewed two-commit series plus this gate checklist. |
+| 7 | Single feature theme | PASS | The series changes one lifecycle example script plus its acceptance tests. Both commits are the red/green pair for making worktree provisioning converge on pre-existing worktrees. |
+
+## Diff Scope
+
+```text
+examples/lifecycle/packs/lifecycle/assets/scripts/worktree-setup.sh | 96 ++++++++++--------
+test/acceptance/worktree_lifecycle_test.go | 110 +++++++++++++++++++++
+test/acceptance/worktree_test.go | 15 ++-
+3 files changed, 175 insertions(+), 46 deletions(-)
+```
+
+## Focused Acceptance Evidence
+
+```text
+PASS TestLifecycleWorktreeSetupBeadRedirect
+PASS TestLifecycleWorktreeSetupRedirectAppliesToPreExistingWorktree
+PASS TestWorktreeBranchNamespacing
+PASS TestWorktreeIdempotent
+PASS TestWorktreeBeadRedirect
+```
diff --git a/release-gates/ga-bp4zyv-symlink-safe-city-root-containment-gate.md b/release-gates/ga-bp4zyv-symlink-safe-city-root-containment-gate.md
new file mode 100644
index 0000000000..971e5e9340
--- /dev/null
+++ b/release-gates/ga-bp4zyv-symlink-safe-city-root-containment-gate.md
@@ -0,0 +1,48 @@
+# Release gate: symlink-safe city-root containment
+
+- Deploy bead: `ga-bp4zyv`
+- Source review: `ga-bnd1fs`
+- Reviewed commit: `026f11a4131964d24c33c6cb5c65d5f785441bf1`
+- Reviewed base: `4a636f6ad88002556c6c0891b7b9e07f9502c81c`
+- Main evaluated: `origin/main@9a88d149cd5c3fb1054f75f8d540fd2aefa465e1`
+- Deploy branch: `deploy/ga-bp4zyv-gate`
+- Evaluated: `2026-07-30T04:36:26Z`
+- Overall verdict: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this repository at the evaluated
+commit, so this checklist applies the deployer role's release-gate criteria.
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first and rechecked after tests. `git merge-tree --write-tree origin/main 026f11a4131964d24c33c6cb5c65d5f785441bf1` exited 0 against `origin/main@9a88d149cd5c3fb1054f75f8d540fd2aefa465e1` and produced tree `1999b4e91bb28274c16f8a7fd8082aa38a6f6220`. No self-rebase or source-branch mutation was needed. |
+| 1 | Review PASS present | **PASS** | The deploy bead records a reviewed-and-passed verdict for exact commit `026f11a4131964d24c33c6cb5c65d5f785441bf1`. The source review reports no style, security, or correctness findings. |
+| 2 | Acceptance criteria met | **PASS** | The focused containment suite passed 12 tests, 0 failed, 0 skipped. It covers both valid symlinked-city forms, six `controllerState.CreateRig` behaviors, and four git-provision rejection paths, including relative escapes, absolute client paths, and symlinked-parent escapes. The implementation normalizes both lexical operands while leaving the independent symlink-aware containment pass unchanged. |
+| 3 | Tests pass | **PASS** | On the exact reviewed SHA: `go build ./...`, `go vet ./...`, and `gofmt -l` passed; `make test-fast-parallel` passed 10/10 jobs (0 fail, 0 skip); the documented non-short `cmd/gc` coverage inside `make test-local-full-parallel` passed all six process shards plus the product-metrics testhook (7/7 jobs, 0 fail, 0 skip); `make test-acceptance` passed the Tier A package (0 fail; five tag-empty packages reported no tests to run); and `make test-worker-core-phase2-all` passed 3/3 package invocations (0 fail, 0 skip). The broad 40-job local sweep also exposed host-only failures outside the changed files: the host `bd` binary differed from the verified CI archive despite sharing its version string, tmux 3.7b returned no builtin key bindings, and Dolt 2.2.1 rejected dirty migration fixtures that CI runs under Dolt 2.1.7. The CI-archive rerun cleared the `bdflags` and formula-retry failures; serial reruns cleared the readiness, live-contract, and cleanup races (4 top-level PASS, 0 FAIL, 5 fixture-required subtest SKIPs). The two remaining host-tool failures are in unchanged tmux/recovery code, and the exact merge-base CI run `30496938823` passed every corresponding required lane. |
+| 4 | No high-severity review findings open | **PASS** | The reviewer reports no security findings and no blocking findings. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Detached reviewed commit `026f11a41` had an empty `git status --porcelain=v1` before this checklist was added; `git diff --check` against the reviewed base passed. The configured hook path is `.githooks`; this checklist is the only deployer-authored release commit. |
+| 7 | Single feature theme | **PASS** | The two-commit TDD diff changes only `cmd/gc/api_state.go` and its focused test (+90/-2). Both commits address one behavior: API rig creation when the city root is reached through a symlink. |
+
+## Review notes
+
+- `assertRigPathWithinCity` reuses `pathutil.NormalizePathForCompare` on the
+ city root and target before the lexical containment check.
+- The second, symlink-aware `EvalSymlinks`/`realPathForContainment` pass is
+ unchanged, so escaping paths still have to pass both containment checks.
+- Local `gc rig add` behavior, API wire shapes, configuration, and storage
+ migrations are unchanged.
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main 026f11a4131964d24c33c6cb5c65d5f785441bf1
+git diff --check 4a636f6ad88002556c6c0891b7b9e07f9502c81c..026f11a4131964d24c33c6cb5c65d5f785441bf1
+gofmt -l cmd/gc/api_state.go cmd/gc/api_state_rig_path_symlink_test.go
+go test -count=1 -v ./cmd/gc -run '^(TestAssertRigPathWithinCityRejectsResolvedTargetUnderRawCity|TestAssertRigPathWithinCityAcceptsWhenBothSidesResolved|TestProvisionRigFromGitRejectsPreexistingPath|TestProvisionRigFromGitRejectsEscapingRelativePath|TestProvisionRigFromGitRejectsAbsoluteClientPath|TestProvisionRigFromGitRejectsSymlinkedParent|TestControllerStateCreateRigPokesReconciler|TestControllerStateCreateRigRejectsDuplicateName|TestControllerStateCreateRigDetectsDefaultBranch|TestControllerStateCreateRigRejectsOutOfCityPath|TestControllerStateCreateRigDetectsDefaultBranchForRelativePath|TestControllerStateCreateRigInitializesStoreBeforePublishing)$'
+go build ./...
+go vet ./...
+make test-local-full-parallel
+PATH=":$PATH" make test-fast-parallel
+PATH=":$PATH" make test-acceptance
+PATH=":$PATH" make test-worker-core-phase2-all
+```
diff --git a/release-gates/ga-djfr2g-formula-gc-rig-scope-gate.md b/release-gates/ga-djfr2g-formula-gc-rig-scope-gate.md
new file mode 100644
index 0000000000..cfe7eef2e3
--- /dev/null
+++ b/release-gates/ga-djfr2g-formula-gc-rig-scope-gate.md
@@ -0,0 +1,29 @@
+# Release gate: formula `GC_RIG` scope resolution
+
+- Deploy bead: `ga-djfr2g`
+- Build bead: `ga-fstubn`
+- Reviewed source: `e25f6e9df1a7b50059c11a0448a12c24aae00b4a`
+- Gate base: `origin/main@e6135a435098a70f20081d1d88a03b6742002d9a`
+- Evaluation date: 2026-07-30
+- Disposition: **PASS**
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Independent review bead `ga-4qlsxg` records `verdict: pass` at the reviewed source SHA. |
+| 2 | Acceptance criteria met | **PASS** | Focused tests pass for valid `GC_RIG` routing outside a registered rig path, explicit `--rig` precedence, invalid/unbound `GC_RIG` warning plus cwd/city fallback, unchanged behavior when `GC_RIG` is unset, and rig-scoped formula variables. The implementation is shared by formula show, catalog, cook, and version-check call sites. |
+| 3 | Tests pass | **PASS** | At the reviewed source SHA: `go build ./...` and `go vet ./...` passed; the focused formula-scope command passed 14 PASS, 0 FAIL, 0 SKIP; `make test-fast-parallel` passed 10/10 jobs; and the required `make test-cmd-gc-process-parallel` coverage passed all six `GC_FAST_UNIT=0` shards plus `productmetrics-testhook`, with 15,247 PASS, 0 FAIL, and 11 intentional skips. `TestTutorial01` ran and passed. The skips are existing helper-only, opt-in live-canary, unsupported-OS, unavailable optional prompt-fixture, or ambient-cwd cases explicitly disabled inside test binaries; none bears on formula scope precedence. |
+| 4 | No high-severity review findings open | **PASS** | Reviewer notes report no style, security, or specification findings and no blocking findings; unresolved HIGH count is 0. |
+| 5 | Final branch is clean | **PASS** | `git status --porcelain` was empty at the reviewed source SHA before this gate record was created. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after tests. `git merge-tree --write-tree origin/main e25f6e9df1a7b50059c11a0448a12c24aae00b4a` exited 0 against the gate base and produced tree `06495988b3b266e76e96f99fdac35647b81abc94`; no self-rebase was required. |
+| 7 | Single feature theme | **PASS** | The three-commit diff (RED `62d4260e0`, GREEN `6c5712c0f`, and this gate-doc refresh) is confined to `cmd/gc/cmd_formula.go`, `cmd/gc/cmd_formula_test.go`, and this gate doc, implementing, testing, and recording one formula scope-resolution behavior — including the restored `--city` scope pin. |
+
+## Acceptance evidence
+
+- `GC_RIG` is consulted after explicit `--rig` and before cwd-based discovery.
+- A valid bound rig selects its store root, formula layers, and formula variables even when the agent worktree is outside the rig path.
+- An unknown or unbound `GC_RIG` does not make formula commands unusable: resolution falls through and emits a warning naming the discarded value and selected scope.
+- Existing cwd and city fallback behavior remains in place when `GC_RIG` is unset.
+- An explicit `--city` pins city scope ahead of `GC_RIG` and cwd discovery.
+- No configuration schema, API wire shape, migration, or new dependency is introduced.
diff --git a/release-gates/ga-huwqp6-named-on-demand-cold-custom-scale-check-wake-gate.md b/release-gates/ga-huwqp6-named-on-demand-cold-custom-scale-check-wake-gate.md
new file mode 100644
index 0000000000..3b9c3faa62
--- /dev/null
+++ b/release-gates/ga-huwqp6-named-on-demand-cold-custom-scale-check-wake-gate.md
@@ -0,0 +1,38 @@
+# Release Gate: Named on-demand cold custom-scale-check wake
+
+- Deploy bead: `ga-huwqp6`
+- Source review: `ga-k3jb5n.1.1`
+- Reviewed commit: `b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe`
+- Candidate base: `af42a94245a547a0c47ec26054afa5fd1347b567`
+- Main evaluated: `origin/main@a72480ec884e5f6369f23b84cb18786affa49df5`
+- Deploy branch: `deploy/ga-huwqp6-gate`
+- Evaluated: `2026-07-28T04:46:33Z`
+- Overall verdict: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this repository at the evaluated
+commit, so this checklist applies the deployer role's release-gate criteria.
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first after fetching `origin/main`. `git merge-tree --write-tree origin/main b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe` exited 0 and produced tree `47cf1c92546e38bd376d179996de5c4fd014fd43`. No self-rebase or source-branch mutation was needed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-k3jb5n.1.1` is closed with `REVIEW VERDICT: PASS` and `FINAL VERDICT: PASS` for exact commit `b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe`. |
+| 2 | Acceptance criteria met | **PASS** | The cold-wake probe for an `on_demand` named-session-backing pool with a custom `scale_check` now feeds `defaultScaleTargets` and records the template in `coldWakeTemplates`, allowing generic `gc.routed_to` demand to reach the existing named-session wake signal. The new regression test proves the routed-demand count and guards against phantom named-identity materialization. The `namedSessionMode == "always"` suppression boundary remains green. The retired deploy's unrelated parent `7eb9f2d7e3d07b2ec7ab175b6897531c3b56c6c5` is absent from the reviewed commit's ancestry. |
+| 3 | Tests pass | **PASS** | First-attempt checks on the exact reviewed SHA passed: `gofmt -l` on both changed files was empty; the focused regression plus two `always`-mode boundary tests passed; `go build ./...` passed; `go vet ./...` passed; and `make test-fast-parallel` passed all nine jobs (`fsys-darwin-compile`, `push-gate-lock-selftest`, `unit-core`, and all six `unit-cmd-gc` shards). |
+| 4 | No high-severity review findings open | **PASS** | The exact-SHA review reports no security findings, no coverage gaps, and no blockers. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this checklist, detached `b14fc3390` had an empty `git status --porcelain=v1`; `git diff --check` against its merge base passed. The configured hook path is `.githooks`; this checklist is the only deployer-authored release commit. |
+| 7 | Single feature theme | **PASS** | The reviewed commit is one commit touching two files in one subsystem: `cmd/gc/build_desired_state.go` and its unit test (+82/-1). It fixes only cold routed-demand visibility for named on-demand pools with a custom `scale_check`. |
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe
+git merge-base origin/main b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe
+git merge-base --is-ancestor 7eb9f2d7e3d07b2ec7ab175b6897531c3b56c6c5 b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe
+git diff --check af42a94245a547a0c47ec26054afa5fd1347b567..b14fc3390fdea034dcd5e4fa6638fde9bb4e8afe
+gofmt -l cmd/gc/build_desired_state.go cmd/gc/build_desired_state_test.go
+go test ./cmd/gc/... -run 'TestBuildDesiredState_OnDemandNamedSession_ColdCustomScaleCheckWakesOnRoutedDemand|TestBuildDesiredState_IncludesImportedAlwaysNamedSessions|TestBuildDesiredState_AlwaysNamedSession_MaterializesWithoutWorkBeads' -count=1 -v
+go build ./...
+go vet ./...
+make test-fast-parallel
+```
diff --git a/release-gates/ga-i6a6ds-local-test-concurrency-cap-gate.md b/release-gates/ga-i6a6ds-local-test-concurrency-cap-gate.md
new file mode 100644
index 0000000000..561bcbe896
--- /dev/null
+++ b/release-gates/ga-i6a6ds-local-test-concurrency-cap-gate.md
@@ -0,0 +1,41 @@
+# Release Gate: Local test concurrency cap
+
+- Deploy bead: `ga-i6a6ds`
+- Source review: `ga-8b8vzk`
+- Reviewed commit: `cc194b367a62ec3d21339c095c5d354b2c9b7468`
+- Candidate base: `311effd094d3a5085c364d4cab017f65442d43b8`
+- Main evaluated: `origin/main@a72480ec884e5f6369f23b84cb18786affa49df5`
+- Deploy branch: `deploy/ga-i6a6ds-gate`
+- Evaluated: `2026-07-28T05:23:02Z`
+- Overall verdict: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this repository at the evaluated
+commit, so this checklist applies the deployer role's release-gate criteria.
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first after fetching `origin/main`. `git merge-tree --write-tree origin/main cc194b367a62ec3d21339c095c5d354b2c9b7468` exited 0 and produced tree `7d72b00c11803675166dfb90bfb9e6b33fd281f6`. The earlier real conflict was resolved on the reviewed branch; no deploy-time rebase or source-branch mutation was needed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-8b8vzk` records the earlier request-changes verdict, followed by `REVIEW VERDICT: PASS (re-review after rebase + rework)` and `FINAL VERDICT: PASS` for exact commit `cc194b367a62ec3d21339c095c5d354b2c9b7468`. |
+| 2 | Acceptance criteria met | **PASS** | `test-local-job-count` subtracts a validated load-derived reduction from the CPU/memory budget while preserving the minimum floor and explicit CPU override. `gc_inner_parallelism` divides that outer budget across concurrent jobs, and `test-local-parallel` exports the result through `GOFLAGS=-p=`. The runner registers the 25-assertion self-test in fast and full modes. Rebase conflict resolution preserves both the prior push-gate environment controls and this feature's load-average control. Comments accurately scope `-p` to cross-package/build concurrency rather than within-package `t.Parallel()` fan-out. |
+| 3 | Tests pass | **PASS** | First-attempt runtime checks on the exact reviewed SHA passed: 10 focused concurrency subtests plus the environment-allowlist test; `scripts/test-local-concurrency.sh` 25/25; full `go test ./scripts/...`; `go build ./...`; `go vet ./...`; and `make test-fast-parallel` all 10 jobs, with the runner reporting `inner_p=1`. `gofmt -l` and `bash -n` were clean. ShellCheck passed on all new/focused shell files and on the modified runner with two documented legacy info codes excluded. A broad invocation stopped only on pre-existing `SC1091`/`SC2016` informational findings outside the changed hunks; it found no new warning in this feature. |
+| 4 | No high-severity review findings open | **PASS** | The prior blocking merge-conflict finding and non-blocking comment-accuracy finding were both fixed and independently re-reviewed. The final review reports no security findings or new blockers. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this checklist, detached `cc194b367` had an empty `git status --porcelain=v1`; `git diff --check` against its merge base passed. The configured hook path is `.githooks`; this checklist is the only deployer-authored release commit. |
+| 7 | Single feature theme | **PASS** | The three reviewed commits touch five files in one subsystem: local test-runner concurrency budgeting, its direct shell self-test, and the environment-allowlist contract needed to keep the runner deterministic. No independent product feature, CI workflow, timeout, coverage, or resource-ledger change is bundled. |
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main cc194b367a62ec3d21339c095c5d354b2c9b7468
+git diff --check 311effd094d3a5085c364d4cab017f65442d43b8..cc194b367a62ec3d21339c095c5d354b2c9b7468
+gofmt -l scripts/precommit_contract_test.go
+bash -n scripts/lib/inner-parallelism.sh scripts/test-local-concurrency.sh scripts/test-local-job-count scripts/test-local-parallel
+shellcheck -P scripts -P scripts/lib scripts/lib/inner-parallelism.sh scripts/test-local-concurrency.sh scripts/test-local-job-count
+shellcheck -e SC1091,SC2016 -P scripts -P scripts/lib scripts/test-local-parallel
+go test ./scripts/... -run 'TestTestFastParallelUsesSanitizedEnvironmentAndMachineAwareConcurrency|TestLocalParallelAllowlistIncludesObservableEnv' -count=1 -v
+bash scripts/test-local-concurrency.sh
+go test ./scripts/... -count=1
+go build ./...
+go vet ./...
+make test-fast-parallel
+```
diff --git a/release-gates/ga-jhs26o-controller-hang-deadline-migration-gate.md b/release-gates/ga-jhs26o-controller-hang-deadline-migration-gate.md
new file mode 100644
index 0000000000..0fdfea4971
--- /dev/null
+++ b/release-gates/ga-jhs26o-controller-hang-deadline-migration-gate.md
@@ -0,0 +1,59 @@
+# Release Gate: controller test hang-deadline migration
+
+Date: 2026-07-28
+Deployer: `gascity/deployer`
+Deploy bead: `ga-jhs26o`
+Reviewed commit: `4304df38b9758d2d5fcdfe32453b950f9cddeb40`
+Base checked: `origin/main` at `f68a2ed019a21d9efc41ed1d02c9233eeb8463de`
+
+`docs/PROJECT_MANIFEST.md` is not present in this checkout. This evaluation
+therefore uses the deployer release criteria and the repository's canonical
+`TESTING.md` policy.
+
+## Release Criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | Review bead `ga-opw5az` is closed and records `REVIEW VERDICT: PASS` for the exact reviewed commit. |
+| 2 | Acceptance criteria met | PASS | Both repository guards pass. The literal-deadline scan now returns exactly four intentional exclusions, all with specific comments. The migration removes six `time.Sleep` calls and adds none; the three fixed-sleep census baselines fall by exactly six and the live census/documentation sync guard passes. `cmd/gc/hangbudget_test.go` and `cmd/gc/cmd_stop_test.go` have no diff. |
+| 3 | Tests pass | PASS | `go build ./...`, `go vet ./...`, the two focused controller lint tests, `TestRepositoryLedgerMatchesCensusAndDocumentation`, and `make test-fast-parallel` all passed. The sharded fast run completed 9/9 jobs successfully. |
+| 4 | No high-severity review findings open | PASS | The review records no blockers and no HIGH or CRITICAL findings. |
+| 5 | Final branch is clean | PASS | `git status --porcelain` was empty on `deploy/ga-jhs26o-gate` before this checklist was written. This checklist is the deployer's only additional change and will be committed separately. |
+| 6 | Branch diverges cleanly from main | PASS | Evaluated first and rechecked after the test run. `git merge-tree --write-tree origin/main HEAD` succeeded against current `origin/main`, producing tree `d3a7b9095a884df253d8a6913cab4d595496a4b1`. No self-rebase was needed. |
+| 7 | Single feature theme | PASS | The two-commit range has one theme: migrating `cmd/gc/controller_test.go` hang guards to the existing wait helpers. The lint test and synchronized resource-census reductions directly enforce and account for that migration. |
+
+## Acceptance Evidence
+
+- The reviewed range contains two commits and changes five files:
+ `cmd/gc/controller_test.go`, its new lint test, and the three synchronized
+ resource-census artifacts.
+- `grep -cE 'time\.After\([0-9]|time\.Now\(\)\.Add\([0-9]' cmd/gc/controller_test.go`
+ returns `4`. Those sites are the documented scenario-input,
+ negative-assertion-window, and bounded-best-effort exclusions.
+- `TestControllerTestHasNoUnmigratedRawHangDeadlines` and
+ `TestControllerTestNoFunctionMixesHangBudgetWithRawDeadline` both pass.
+- The diff removes six `time.Sleep(...)` calls and adds zero. The all-source
+ fixed-sleep baseline moves `427 -> 421`; both untagged baselines move
+ `288 -> 282`.
+- `TestRepositoryLedgerMatchesCensusAndDocumentation` passes, proving the live
+ source census, `internal/testpolicy/resourcecensus/census.go`,
+ `test/test-resources.toml`, and `TESTING.md` agree.
+
+## Commands Run
+
+```text
+git fetch origin main
+git merge-tree --write-tree origin/main HEAD
+git diff --check ..HEAD
+go test -count=1 ./cmd/gc/... -run 'TestControllerTestHasNoUnmigratedRawHangDeadlines|TestControllerTestNoFunctionMixesHangBudgetWithRawDeadline' -v
+go test -count=1 ./internal/testpolicy/resourcecensus/... -run TestRepositoryLedgerMatchesCensusAndDocumentation -v
+go build ./...
+go vet ./...
+make test-fast-parallel
+```
+
+## Decision
+
+PASS. The isolated deploy branch is ready for merge-authority review. The
+related `ga-003f4o` deploy remains held pending this landing, and `ga-it1j7l`
+remains responsible for the subsequent rebase/subsume determination.
diff --git a/release-gates/ga-jx0gqf-normalize-configured-paths-gate.md b/release-gates/ga-jx0gqf-normalize-configured-paths-gate.md
new file mode 100644
index 0000000000..509c70ab79
--- /dev/null
+++ b/release-gates/ga-jx0gqf-normalize-configured-paths-gate.md
@@ -0,0 +1,66 @@
+# Release gate: normalize configured city and rig paths at ingest
+
+- Deploy bead: `ga-jx0gqf`
+- Build bead: `ga-iawy13.8`
+- Source review: `ga-lb56pa`
+- Reviewed commit: `5dc166233f37aff9817be18c7a38a33b70e1ebd5`
+- Reviewed base: `2ff1536d9b014ea9728f46bbe7ece6f3378d76ad`
+- Main evaluated: `origin/main@1f948e67b0ac088492af67c0748f521aad5768b0`
+- Deploy branch: `deploy/ga-jx0gqf-gate`
+- Evaluated: `2026-08-03T18:16:05Z`
+- Overall verdict: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present at the evaluated commit, so this
+checklist applies the deployer role's release-gate criteria together with
+`engdocs/contributors/release-gate-criteria-conventions.md`.
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first and rechecked after tests. `git merge-tree --write-tree origin/main 5dc166233f37aff9817be18c7a38a33b70e1ebd5` exited 0 against `origin/main@1f948e67b0ac088492af67c0748f521aad5768b0` and produced tree `6e80d2f2ce92899e47d232c6b12815253142242a`. The reviewed SHA remained the deploy source; no remote source branch was changed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-lb56pa` is closed with reason `pass` for exact commit `5dc166233f37aff9817be18c7a38a33b70e1ebd5`. The review records `verdict: pass`, no style findings, and no blocking security or correctness findings. |
+| 2 | Acceptance criteria met | **PASS** | Nine focused tests passed, 0 failed, 0 skipped. The four TDD regressions prove symlink-ancestor convergence for `--city`, `GC_CITY_PATH`, positional city/rig paths, and the `GC_RIG_ROOT`/`BEADS_DIR` projection. Existing passing contracts cover relative/local city input and source precedence, missing leaves through `pathutil.NormalizePathForCompare`, and contextual unknown-city errors. The three production changes replace inconsistent `Abs`/`Clean` ingest with the shared normalizer; no schema, flag, environment-variable, or API contract changes. The build/review notes inventory `city.toml`, `--city`, `--rig`, `GC_CITY*`, and `GC_RIG_ROOT`, and verify already-canonical or out-of-increment seams rather than adding duplicate downstream normalization. |
+| 3 | Tests pass | **PASS** | On the exact reviewed SHA, `go build ./...`, `go vet ./...`, `gofmt -l` on all four changed files, and `git diff --check` passed. `make test-fast-parallel` passed 10/10 jobs (0 fail, 0 skip). The documented non-short CLI lane ran with `GC_FAST_UNIT=0` and the checksum-pinned CI `bd` archive: 15,362 PASS, 0 FAIL, 11 SKIP; the skips are helper-only, platform/opt-in, optional-pack, or ambient-CWD fallback cases, and none exercises the migrated explicit-ingest branches. The product-metrics testhook passed 12, failed 0, skipped 0. Worker phase 2 passed 26/26 requirements for each of Claude, Codex, and Gemini (78 PASS, 0 FAIL, 0 unsupported). Focused acceptance coverage passed 9/9. The PR integration smoke/core/cmd-gc/bdstore jobs and an isolated review-formula retry passed. The broad local RC stress sweep additionally exposed unchanged host-only limitations: tmux 3.7b does not return builtin key bindings without a server, and five `rest-full` shards timed out waiting for supervisors during the 29-way run. Those are outside the four-file diff; the exact merge-base CI run [30826419301](https://github.com/gastownhall/gascity/actions/runs/30826419301) and current-main CI run [30833610783](https://github.com/gastownhall/gascity/actions/runs/30833610783) passed the corresponding lanes. |
+| 4 | No high-severity review findings open | **PASS** | The reviewer reports no blocker or major style, correctness, or security findings. The only informational note is the shared normalizer's pre-existing best-effort fallback if `filepath.Abs` cannot resolve a relative path. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | The detached reviewed commit had an empty `git status --short` before this checklist was added. `git diff --check 2ff1536d9b014ea9728f46bbe7ece6f3378d76ad..5dc166233f37aff9817be18c7a38a33b70e1ebd5` passed, and `core.hooksPath` is `.githooks`. This checklist is the only deployer-authored release commit. |
+| 7 | Single feature theme | **PASS** | The two-commit TDD set changes four files in `cmd/gc` (+112/-9), all for one behavior: canonicalizing configured city and rig paths once at their CLI/environment ingest boundaries. No independent feature is bundled. |
+
+## Acceptance evidence
+
+| Surface | Owning boundary | Evidence |
+|---|---|---|
+| `--city`, `GC_CITY`, `GC_CITY_PATH`, `GC_CITY_ROOT` | `validateCityPath` | `TestResolveCityFlagValueResolvesSymlinkAlias`, `TestResolveExplicitCityPathEnvResolvesSymlinkAlias` |
+| Positional city/rig path | `resolveContextFromPath` | `TestResolveCommandContextPathArgResolvesSymlinkAlias` |
+| `GC_RIG_ROOT`, `BEADS_DIR` | `bdRuntimeEnvForRigWithErrorRecoveryContext` | `TestBdRuntimeEnvForRigResolvesSymlinkAlias` |
+| Relative/local input and source precedence | Existing city reference resolver | `TestNormalizePathForCompare`, `TestResolveExplicitCityPathEnvLocalWinsOverRegistration` |
+| Missing leaf under a symlinked ancestor | Shared `pathutil` normalizer | `TestNormalizePathForCompareResolvesSymlinkAncestorForMissingLeaf` |
+| Contextual invalid-city error | Existing city reference resolver | `TestResolveCityRefNameNoMatchLoudError` |
+
+## Review notes
+
+- This is internal path canonicalization only. It adds no configuration fields,
+ flags, environment variables, endpoints, migrations, or dependencies.
+- `--rig` and `city.toml` paths already converge through their existing
+ normalized registry/config boundaries; this increment fixes only the three
+ proven gaps whose raw string values could escape.
+- The diff replaces three local `filepath.Abs`/`filepath.Clean` operations with
+ the existing `normalizePathForCompare` wrapper. It does not add another
+ normalization mechanism.
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main 5dc166233f37aff9817be18c7a38a33b70e1ebd5
+git diff --check 2ff1536d9b014ea9728f46bbe7ece6f3378d76ad..5dc166233f37aff9817be18c7a38a33b70e1ebd5
+gofmt -l cmd/gc/bd_env.go cmd/gc/bd_env_test.go cmd/gc/city_arg_resolve_test.go cmd/gc/main.go
+go build ./...
+go vet ./...
+make test-fast-parallel
+GC_FAST_UNIT=0 scripts/go-test-observable gate-cmd-gc-process -- -timeout 25m ./cmd/gc
+make test-productmetrics-testhook
+make test-worker-core-phase2-all PROFILE=claude/tmux-cli
+make test-worker-core-phase2-all PROFILE=codex/tmux-cli
+make test-worker-core-phase2-all PROFILE=gemini/tmux-cli
+go test -count=1 -v ./internal/pathutil ./cmd/gc -run ''
+make test-integration-shards-parallel
+```
diff --git a/release-gates/ga-m6unmy-workspacesvc-proxy-process-orphan-gate.md b/release-gates/ga-m6unmy-workspacesvc-proxy-process-orphan-gate.md
new file mode 100644
index 0000000000..4cc0a1bb1d
--- /dev/null
+++ b/release-gates/ga-m6unmy-workspacesvc-proxy-process-orphan-gate.md
@@ -0,0 +1,49 @@
+# Release Gate: workspacesvc proxy-process orphan prevention
+
+- Deploy bead: `ga-m6unmy`
+- Source branch (provenance only): `builder/ga-m6unmy-gate-rebase`
+- Evaluated source commit: `9d13719c848abe62d13381af32600ab45c3764ac`
+- Base checked: `origin/main` at `31ee5bd4e9ee3ca6d9411d06972666a712803071`
+- Isolated deploy branch: `deploy/ga-m6unmy-gate`
+- Overall result: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this checkout. This checklist
+applies the release criteria supplied in the deployer instructions and the
+test boundaries documented in `TESTING.md`.
+
+## Checklist
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | **PASS** | The reviewer recorded `verdict: pass` after independently reviewing the production change, Linux/non-Linux split, hard-exit proof, TestMain leak detector, security, and style at the original green commit. The rebased candidate adds the required mirrored resource-census update; mayor then independently checked its proportionality against the branch diff, cleared `hold:mayor`, and explicitly ruled `PROCEED TO DEPLOY. Do not route back to reviewer.` |
+| 2 | Acceptance criteria met | **PASS** | Linux proxy children receive kernel-enforced `Pdeathsig: SIGKILL`; non-Linux builds retain the previous `Setpgid` behavior; a real re-exec harness proves the child dies after a direct `os.Exit` with no Go cleanup; package `TestMain` fails on surviving direct children; no Family B files or `gc dolt-cleanup` behavior changed. The resource-census increase is exactly proportional to this branch's two new subprocess and two new fixed-sleep call sites and is mirrored in `census.go`, `test-resources.toml`, and `TESTING.md`. |
+| 3 | Tests pass | **PASS** | `go build ./...`: PASS. `go vet ./...`: PASS. Documented CI-equivalent `make test-fast-parallel`: 10 PASS jobs, 0 FAIL jobs, 0 SKIP jobs. A JSON-counted full affected-package run reported 59 PASS tests, 0 FAIL, 8 SKIP. Two skips are re-exec helper entry points that intentionally run only with their harness environment; six orphan-reaper tests require direct init-parenting and safely skip because this host has a child subreaper. Focused hard-exit, survivor-detector, and live resource-ledger tests: 3 PASS, 0 FAIL, 0 SKIP. `GOOS=darwin go test -c ./internal/workspacesvc`: PASS. |
+| 4 | No high-severity review findings open | **PASS** | Reviewer reported no blocker, major, security, or style findings. Mayor's follow-up proportionality audit found the census delta exact and required for CI. Unresolved HIGH findings: 0. |
+| 5 | Final branch is clean | **PASS** | The detached candidate and newly cut isolated deploy branch were clean before adding this gate checklist. No generated files or test artifacts are present in the branch. |
+| 6 | Branch diverges cleanly from main | **PASS** | `git rev-list --left-right --count origin/main...9d13719c...` reported `0 3`: the candidate contains current main and is three feature commits ahead. `git merge-tree --write-tree origin/main 9d13719c...` returned 0 with no conflicts. |
+| 7 | Single feature theme | **PASS** | All changes implement one feature theme: preventing and detecting orphaned `proxy_process` test children. The three resource-ledger files are the mandatory census mirror for the new tests, not an independent feature. |
+
+## Acceptance Evidence
+
+- `TestProxyProcessSurvivesHardParentExit` passed against the production
+ `Manager.Reload` path and a direct `os.Exit` harness.
+- `TestLivingTestChildrenDetectsSurvivor` passed for both live-child detection
+ and post-reap disappearance.
+- `TestRepositoryLedgerMatchesCensusAndDocumentation` passed against the live
+ repository AST.
+- The Darwin test-binary compile passed, proving the `!linux` process-attribute
+ implementation remains buildable.
+
+## Test Commands
+
+```text
+go build ./...
+go vet ./...
+go test ./internal/workspacesvc/... -count=1 \
+ -run 'TestProxyProcessSurvivesHardParentExit|TestLivingTestChildrenDetectsSurvivor' -v
+go test ./internal/testpolicy/resourcecensus/... -count=1 \
+ -run TestRepositoryLedgerMatchesCensusAndDocumentation -v
+GOOS=darwin go test -c -o ./internal/workspacesvc
+make test-fast-parallel
+go test -json -count=1 ./internal/workspacesvc/...
+```
diff --git a/release-gates/ga-pfdabs-dolt-identity-tempdir-cleanup-gate.md b/release-gates/ga-pfdabs-dolt-identity-tempdir-cleanup-gate.md
new file mode 100644
index 0000000000..b95faaec3d
--- /dev/null
+++ b/release-gates/ga-pfdabs-dolt-identity-tempdir-cleanup-gate.md
@@ -0,0 +1,32 @@
+# Release gate: isolate Dolt test identity from `t.TempDir` cleanup
+
+- Deploy bead: `ga-pfdabs`
+- Build bead: `ga-7dgcg6`
+- Review bead: `ga-0gqma7`
+- Reviewed commit: `25148bc121317fb357d84f43fbd53eabdca64f6e`
+- Gate base: `origin/main` at `29b36facde4ffe557b6fb5b99c7375468600b606`
+- Evaluated: 2026-07-31
+- Result: **PASS**
+
+Criterion 6 was evaluated first, as required. The remaining criteria were then
+evaluated in numeric order. `docs/PROJECT_MANIFEST.md` is absent from both the
+reviewed commit and current `origin/main`; this checklist therefore applies the
+deployer gate criteria and
+`engdocs/contributors/release-gate-criteria-conventions.md` directly.
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Review bead `ga-0gqma7` is closed with reason `pass`; its notes record `verdict: pass` and pin deploy commit `25148bc121317fb357d84f43fbd53eabdca64f6e`. |
+| 2 | Acceptance criteria met | **PASS** | The reviewed diff adds `doltIdentityHomeDir`, places Dolt/Git identity files outside every `t.TempDir` tree, redirects `configureTestDoltIdentityEnv` to it, and widens the leak guard to `cityPath`, `feRepoDir`, and the identity home. The regression test fails on RED commit `296cc5920` and passes on the reviewed commit. `GC_FAST_UNIT=0 go test ./cmd/gc/ -run '^TestBdRigWorktreeStoreConsistentAcrossRawBdGcBdAndProviderStore$' -count=3` passed all 3 repetitions. `gofmt -l` returned no files. The reviewer also verified the remaining shared-helper call sites and `go vet ./...`. |
+| 3 | Tests pass | **PASS** | Required target `make test-cmd-gc-process-parallel` was run in detached worktrees at merge-base `4a636f6ad88002556c6c0891b7b9e07f9502c81c` and reviewed commit `25148bc121317fb357d84f43fbd53eabdca64f6e`. Both sides produced **4 PASS jobs, 3 FAIL jobs, 0 SKIP jobs** and the identical failing set: `TestEvaluatePoolDefaultScaleCheckCountsRoutedReadyWork`, `TestEvaluatePoolDefaultScaleCheckIgnoresRoutedActiveUnassignedWork`, and `TestBuildDesiredState_MinZeroDefaultScaleCheckRoutedWorkCreatesPoolSession`. Shards 4-6 and `productmetrics-testhook` passed on both sides. The pre-push `make test-fast-parallel` run likewise produced **9 PASS jobs, 1 FAIL job, 0 SKIP jobs**; its sole failure, `TestCustomTypesCheck_TableDrift`, was reproduced at both the merge-base and reviewed SHA with the identical missing-`tst` error. These failures are the known ambient-HOME Dolt leak (`ga-zxpfic`): real `bd` is redirected to fleet server `127.0.0.1:3308`, where temporary databases are absent. Both differentials therefore show **0 change-introduced regressions**; the environment fix is tracked by `ga-8pkpor`. The shard wrappers do not emit exact per-test PASS/SKIP counts for red shards, so no unsupported aggregate is claimed. Process-suite logs: `/var/tmp/gc-local-tests.h62qwY` (merge-base) and `/var/tmp/gc-local-tests.lCATVj` (reviewed); pre-push log: `/var/tmp/gc-local-tests.ZRvOxj`; focused doctor logs: `/var/tmp/gc-ga-pfdabs-diff.e7RVAA/{base,reviewed}.doctor.log`. |
+| 4 | No high-severity review findings open | **PASS** | Review notes record no style, security, or specification findings and no unresolved HIGH findings. |
+| 5 | Final branch is clean | **PASS** | The isolated gate worktree was clean at gate commit parent `25148bc121317fb357d84f43fbd53eabdca64f6e` before this checklist was amended; the checklist is the only gate-commit delta. |
+| 6 | Branch diverges cleanly from main | **PASS** | After fetching `origin/main`, `git merge-tree --write-tree origin/main 25148bc121317fb357d84f43fbd53eabdca64f6e` exited 0 and produced tree `96f5fcbae551d89a868720d2f18e93de9ef47078`; no self-rebase was required. |
+| 7 | Single feature theme | **PASS** | The two-commit change touches only `cmd/gc/testenv_test.go` and `cmd/gc/cmd_bd_test.go`, both within the Dolt-backed `cmd/gc` test-environment cleanup theme. |
+
+## Gate decision
+
+The reviewed change introduces no process-suite regression relative to its
+merge-base, satisfies its focused RED/GREEN acceptance evidence, and remains
+conflict-free with current `origin/main`. It is eligible for an isolated deploy
+branch and pull request.
diff --git a/release-gates/ga-pkz5av-git-safety-convention-gate.md b/release-gates/ga-pkz5av-git-safety-convention-gate.md
new file mode 100644
index 0000000000..b92fd31707
--- /dev/null
+++ b/release-gates/ga-pkz5av-git-safety-convention-gate.md
@@ -0,0 +1,21 @@
+# Release gate: Git pathspec-checkout safety convention
+
+- Deploy bead: `ga-pkz5av`
+- Reviewed source: `6790090f180c15a40fd24fc94c6e770f3b6fa5a8`
+- Source branch: `builder/ga-cm51rh` (provenance only)
+- Base: `origin/main` at `d27aeadf46916ebc256c72df5131db0ea7e99876`
+- Overall verdict: **PASS**
+
+| # | Criterion | Verdict | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Review bead `ga-2ggo42` records `REVIEWER VERDICT: PASS` against the exact reviewed SHA. |
+| 2 | Acceptance criteria met | **PASS** | The diff adds one eight-line **Git safety** bullet immediately after **Tmux safety** in `AGENTS.md`. It names the destructive pathspec checkout, the safe `git show :` read, and isolated-worktree alternatives. No script, hook, alias, or Go file changed. The required guidance was also mirrored to still-open bead `ga-ueq90`. |
+| 3 | Tests pass | **PASS** | On an isolated checkout at the reviewed SHA: `make check-docs` passed; the same package rerun through `scripts/go-test-observable gate-docsync -- -count=1 ./test/docsync` recorded **13 PASS, 0 FAIL, 0 SKIP tests**; `make test-fast-parallel` recorded **10 PASS, 0 FAIL, 0 SKIP jobs**; `go vet ./...` passed. No skip justification is required. The `AGENTS.md`-only diff matches none of the optional process/integration path filters in `.github/workflows/ci.yml`. |
+| 4 | No high-severity review findings open | **PASS** | The reviewer reported no issues; unresolved HIGH findings: **0**. |
+| 5 | Final branch is clean | **PASS** | The isolated checkout reported zero status entries before and after the gate commands. |
+| 6 | Branch diverges cleanly from main | **PASS** | After the gate began, `origin/main` advanced by one unrelated tmux commit. The final divergence is `1` base-only and `1` source-only from merge base `30df2e64db3afd11bd18b4fc2cdd61c20b061f69`. `git merge-tree --write-tree origin/main 6790090f180c15a40fd24fc94c6e770f3b6fa5a8` completed without conflicts and produced tree `3dfb270014a60069ca11dfbaf19a3935684a7840`. |
+| 7 | Single feature theme | **PASS** | One contributor-guidance file changed for one Git worktree-safety convention. |
+
+## Release decision
+
+The change is ready for an isolated deploy branch and pull request.
diff --git a/release-gates/ga-qcgakt-routed-test-rows-citation-gate.md b/release-gates/ga-qcgakt-routed-test-rows-citation-gate.md
new file mode 100644
index 0000000000..a6c884aa1b
--- /dev/null
+++ b/release-gates/ga-qcgakt-routed-test-rows-citation-gate.md
@@ -0,0 +1,68 @@
+# Release Gate: fix stale docs/plans citation in check-routed-test-rows.sh
+
+Bead: ga-qcgakt
+Source bead: ga-h7ppr8
+Implementation bead: ga-f74ph9.3
+Branch under review (provenance only): builder/ga-f74ph9.3
+Reviewed commit: ea26fc3d7
+Deploy branch: deploy/ga-qcgakt-gate
+Gate SHA: 9e0983a61 (cherry-pick of ea26fc3d7 onto origin/main@7a739e29b)
+Gate date: 2026-07-26
+
+Note: docs/PROJECT_MANIFEST.md is not present in this worktree. This gate uses
+the deployer release criteria and the repo testing guidance in TESTING.md.
+
+## Background
+
+The first deploy attempt on reviewed SHA ea26fc3d7 (local gate tip cf3da432c)
+failed the mandatory pre-push `make test-fast-parallel` run on an unrelated
+pre-existing flake: `TestCmdStopWallClockTimeoutBoundsDirectStop` exceeded its
+1s bound under sharded load. That flake's fix (the "evidence-based 5s
+remediation", commit 25eb009e8) was already on `origin/main` at gate time but
+not yet in the reviewed branch's base. Per the routed gate-FAIL instruction,
+this gate re-cuts the same one-line fix on a fresh `deploy/ga-qcgakt-gate`
+branch built directly from current `origin/main`, so the resulting SHA
+contains the flake fix.
+
+## Gate Results
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | ga-h7ppr8 review verdict PASS on ea26fc3d7; deploy bead ga-qcgakt created by gascity/reviewer with that reviewed commit. |
+| 2 | Acceptance criteria met | PASS | `scripts/check-routed-test-rows.sh:116` no longer cites the nonexistent `docs/plans/ga-h6w-read-path-api-routing.md`; the hint now points to the six-row matrix definition in this script's own header comment (bead ga-h6w), matching the reviewed content of ea26fc3d7. |
+| 3 | Tests pass | PASS | `go build ./...`, `go vet ./...`, `go test ./cmd/gc -run TestRoutedRowsManifestFullyCovered -count=1`, and `make check-routed-test-rows` all green on 9e0983a61. Full `make test-fast-parallel`: 9/9 fast jobs passed (see Commands log). |
+| 4 | No high-severity review findings open | PASS | Single-line static-string message change, no interpolation, no new attack surface; ga-h7ppr8 review recorded no open findings. |
+| 5 | Final branch is clean | PASS | `git status --short` empty before this gate file was added; this file is committed as the branch tip. |
+| 6 | Branch diverges cleanly from main | PASS | `git merge-tree --write-tree origin/main HEAD` succeeded, produced tree 3f25cb2223a9652847c903849eeb13d8a1ecec08; `git diff --check origin/main...HEAD` reported no conflict markers or whitespace errors. |
+| 7 | Single feature theme | PASS | The commit touches exactly one file, `scripts/check-routed-test-rows.sh` (1 insertion, 1 deletion) — the stale-citation fix only. |
+
+## Acceptance Checks
+
+- PASS: `check-routed-test-rows.sh`'s manifest-violation hint no longer
+ references a deleted docs/plans path.
+- PASS: The six-row matrix rule itself is unchanged — this is a message-text
+ fix only, not a behavior change to the check.
+- PASS: `deploy/ga-qcgakt-gate` is built from current `origin/main`
+ (7a739e29b), so the previously-blocking `TestCmdStopWallClockTimeoutBoundsDirectStop`
+ flake fix (25eb009e8) is included in this gate SHA.
+- PASS: `builder/ga-f74ph9.3` (provenance branch) was not pushed to or
+ otherwise touched by this deploy.
+
+## Commands
+
+```text
+git diff --stat origin/main HEAD
+go build ./...
+gofmt -l scripts/check-routed-test-rows.sh
+go vet ./...
+go test ./cmd/gc -run TestRoutedRowsManifestFullyCovered -count=1
+make check-routed-test-rows
+LOCAL_TEST_JOBS=16 CMD_GC_PROCESS_TOTAL=6 ./scripts/test-local-parallel fast
+git diff --check origin/main...HEAD
+git merge-tree --write-tree origin/main HEAD
+```
+
+All commands above were run on gate SHA 9e0983a61; the full fast-parallel
+suite result: 9/9 jobs passed (`unit-core`, `fsys-darwin-compile`,
+`push-gate-lock-selftest`, `unit-cmd-gc-1-of-6` through `unit-cmd-gc-6-of-6`),
+`EXIT:0`.
diff --git a/release-gates/ga-sdcjgv-canonical-path-ingest-gate.md b/release-gates/ga-sdcjgv-canonical-path-ingest-gate.md
new file mode 100644
index 0000000000..5170d917db
--- /dev/null
+++ b/release-gates/ga-sdcjgv-canonical-path-ingest-gate.md
@@ -0,0 +1,97 @@
+# Release Gate: Canonical path ingest for formulas, workflows, and skills
+
+- Deploy bead: `ga-sdcjgv`
+- Build bead: `ga-iawy13.6`
+- Review bead: `ga-q8rpff`
+- Reviewed commit: `775129cb25b1b96e077eeb85c442e912d58c0dce`
+- Final rebased code commit: `81c5073cc6a8c19c30e70af83928b5fa5fa052b8`
+- Isolated branch: `deploy/ga-sdcjgv-gate`
+- Base: `origin/main` at `c4880aef5f2c6be534358f09354c1d249e32161c`
+- Overall result: **PASS**
+
+The repository does not contain `docs/PROJECT_MANIFEST.md` at this revision.
+This checklist therefore applies the canonical seven deployer release criteria
+plus the repository requirements in `AGENTS.md`, `TESTING.md`, and
+`engdocs/contributors/release-gate-criteria-conventions.md`.
+
+## Criterion 6 evaluated first
+
+**PASS.** The final code commit is cleanly based on `origin/main`.
+
+- `git merge-base --is-ancestor origin/main 81c5073c` returned `0`.
+- `git merge-tree --write-tree origin/main 81c5073c` returned tree
+ `d62e74e354413ca917c27bc93dd11483a9b1d43e` with exit `0`.
+- `origin/main` resolved to
+ `c4880aef5f2c6be534358f09354c1d249e32161c`.
+- The code-only remote deploy ref resolved to
+ `81c5073cc6a8c19c30e70af83928b5fa5fa052b8` before evaluation.
+
+No additional self-rebase was required during this gate cycle.
+
+## Acceptance evidence
+
+All five scoped production sites are comparison or identity preparation and
+delegate to `pathutil.NormalizePathForCompare` on the final code commit:
+
+| Site | Classification | Disposition |
+| --- | --- | --- |
+| `internal/formula/parser.go:descriptionFileBaseDir` | Description-file anchor preparation | Normalize once before deriving the directory. |
+| `internal/formula/source.go:canonicalExistingPath` | Cache-key and `filepath.Rel` preparation | Delegate to the shared normalizer, including multi-level missing tails. |
+| `internal/sourceworkflow/sourceworkflow.go:canonicalScopeRef` | Workflow lock identity | Preserve the empty sentinel; otherwise normalize to a canonical absolute path. |
+| `internal/sourceworkflow/sourceworkflow.go:canonicalCityPath` | Workflow lock identity plus empty-path validation | Preserve validation and normalize the accepted path once. |
+| `internal/materialize/skills.go:canonicalizePath` | Ownership-root and containment comparison | Preserve the call-site contract and delegate to the shared normalizer. |
+
+`rg 'filepath\.EvalSymlinks|EvalSymlinks'` over the four scoped production
+files returned no matches. The final two-commit diff is confined to seven
+files in the formula, source-workflow, and skill-materialization canonical-path
+theme. Regression tests cover symlinked parents, missing leaves, multi-level
+missing tails, and absolute lock identities; existing materializer containment
+coverage remains in place.
+
+## Test evidence integrity
+
+The changed `internal/**` Go paths activate the required process-backed
+`cmd/gc` and PR integration lanes in `.github/workflows/ci.yml`. The final
+evidence ran those documented sharded lanes with the CI-pinned `bd v1.1.0`
+and Dolt `2.1.7`, a short on-disk `/var/tmp` fixture root, and tmux `3.4` for
+the tmux matrix.
+
+- `make test-fast-parallel`: **10 PASS, 0 FAIL, 0 SKIP** at job level.
+- Process-backed `cmd/gc`: **6 PASS, 0 FAIL, 0 SKIP** local shards, plus
+ product-metrics testhook **1 PASS, 0 FAIL, 0 SKIP**.
+- PR integration coverage: core packages **4 PASS**, integration-tagged
+ `cmd/gc` **6 PASS**, runtime tmux **6 PASS**, bdstore **1 PASS**, REST smoke
+ **2 PASS**; total **19 PASS, 0 FAIL, 0 SKIP** at shard/job level.
+- Additional formula-review integration jobs completed **5 PASS, 0 FAIL,
+ 0 SKIP**.
+- `go test -count=1 -json ./internal/formula ./internal/sourceworkflow
+ ./internal/materialize`: **796 PASS, 0 FAIL, 1 SKIP**. The skip is
+ `TestCompileBugReportFlowV2`, whose unrelated external fixture
+ `/home/ubuntu/tooling/formulas/mol-bug-report-flow-v2.toml` is absent.
+- `go vet ./...`: exit `0`.
+- `go build ./...`: exit `0`.
+
+Two setup diagnostics are deliberately excluded from the counts above: an
+initial descriptive temp path exceeded the Unix socket length limit, and a
+clean HOME override was rejected by the platform-supervisor contract. Both
+runs were interrupted after diagnosis. Every affected required shard was then
+rerun in a valid job-specific environment and passed; no PASS is inferred from
+either interrupted diagnostic.
+
+## Release criteria
+
+| # | Criterion | Result | Evidence |
+| --- | --- | --- | --- |
+| 1 | Review PASS present | **PASS** | `ga-q8rpff` is closed with reason `pass`; its notes record `verdict: pass`, no uncovered criteria, and no blocker/major/security findings. |
+| 2 | Acceptance criteria met | **PASS** | The per-site classification matrix covers every scoped production site. All comparison sites use the shared canonicalizer, no scoped bare call remains, validation and call-site contracts are preserved, and the required symlink/missing-tail regressions are covered. |
+| 3 | Tests pass | **PASS** | All path-required CI-equivalent lanes passed with the counts and environment evidence above. The one targeted skip is external-fixture-only and does not exercise this change. |
+| 4 | No high-severity review findings open | **PASS** | Review notes report no blocker, major, HIGH, or CRITICAL findings. Unresolved high-severity finding count: **0**. |
+| 5 | Final branch is clean | **PASS** | The detached evaluation worktree remained pinned to `81c5073c` before and after testing with zero status entries. The isolated deploy branch was reset mechanically to that SHA and was clean before this checklist was added. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first; see the dedicated section above. |
+| 7 | Single feature theme | **PASS** | The two feature commits implement one coherent canonical-path-at-ingest change across formula, workflow-lock, and skill-materialization comparison boundaries. No independent feature is bundled. |
+
+## Gate disposition
+
+The gate passes. Commit this checklist on the isolated deploy branch, push that
+branch only after the shared-branch safety guard passes, open the PR, and route
+the verified merge-request to the merge authority. The deployer does not merge.
diff --git a/release-gates/ga-tg4m6s-named-session-routed-demand-push-guard-retry-gate.md b/release-gates/ga-tg4m6s-named-session-routed-demand-push-guard-retry-gate.md
new file mode 100644
index 0000000000..7ab659022c
--- /dev/null
+++ b/release-gates/ga-tg4m6s-named-session-routed-demand-push-guard-retry-gate.md
@@ -0,0 +1,75 @@
+# Release Gate: Named-session routed-demand wake and push-guard read retry
+
+- Deploy bead: `ga-tg4m6s`
+- Reviewed source: `8137a6d6e73513336c12d3cb9815b185ff4a1773`
+- Source commits:
+ - `ff2621058af04ff57a109fe52cecc2ff07564da1` — wake asleep on-demand named singletons on routed demand
+ - `8137a6d6e73513336c12d3cb9815b185ff4a1773` — bound and retry push-ownership-guard bead reads
+- Review bead: `ga-lstvw3`
+- Base evaluated: `origin/main@c967f1eebef64fe1ad4d9d287fd778fcd796f640`
+- Overall verdict: **PASS**
+
+### Maintainer fixups after the reviewed SHA
+
+The gate below was evaluated at `8137a6d6e`. Maintainer review of the PR
+surfaced integration gaps that were fixed on the branch afterward, so the
+checklist evidence no longer describes the branch head verbatim — the
+corrections are called out inline in criteria 2 and 3:
+
+- `37a364c9d` — classify routed demand as work (`awakeSetToWakeEvals`) and keep
+ its wake through non-interactive sleep suppression.
+- This commit — gate `NamedSessionRoutedDemand` to canonical singleton backing
+ pools, plus this gate refresh.
+
+These are maintainer-side integration fixes to the same feature, not new
+surfaces. They are **not** covered by the `ga-lstvw3` review verdict, which
+closed against `8137a6d6e`.
+
+## Gate checklist
+
+| # | Criterion | Verdict | Evidence |
+|---|-----------|---------|----------|
+| 1 | Review PASS present | **PASS** | Closed review bead `ga-lstvw3` records `REVIEW VERDICT: PASS` for exact commit `8137a6d6e73513336c12d3cb9815b185ff4a1773`, independently verifies both bundled fixes, and concludes: “Both fixes: PASS. No blocking findings.” |
+| 2 | Acceptance criteria met | **PASS** | The asleep named-session alias holder now suppresses a redundant standby while `NamedSessionRoutedDemand` wakes that holder from raw pre-suppression routed demand. The signal is threaded through desired-state/reconciler/awake-set plumbing and remains absent from `mergeNamedSessionDemand`, preserving the wake-only, non-pool-sizing contract. **Corrected after `37a364c9d`:** the original wording also claimed the signal stays absent from `wakeDemandOverridesSleepSuppression`. It is now deliberately present there. Alias suppression zeroes the standby's `poolDesired`, so the pool count cannot carry the signal at that site and the holder would stay asleep under a configured non-interactive sleep policy — the exact wake this feature exists to perform. Explicit sleep intent still wins, so the non-sleep-suppressing intent is preserved for operator-requested sleep. **Scoped after this commit:** the signal is emitted only for canonical singleton backing pools, since a multi-instance pool serves routed demand with an ordinary standby and would otherwise both wake the holder and mint one. The push guard adds environment-overridable `POG_READ_ATTEMPTS` (default 3) to both `bd list` and `bd show` reads, preserves fail-closed behavior, and suggests retry before `--no-verify`. |
+| 3 | Tests pass | **PASS** | Exact-SHA checks passed on the first attempt: six focused routed-demand/alias/reconciler regressions; `scripts/test-push-ownership-guard.sh` (`pass=26 fail=0`), including transient recovery, exhaustion, and real ownership-change blocking; `go test ./scripts/... -count=1 -run TestPushOwnershipGuard`; shell syntax checks; `go build ./...`; `go vet ./...`; and serialized `make test-fast-parallel` with all eight jobs green. |
+| 4 | No high-severity review findings open | **PASS** | `ga-lstvw3` reports no blocking findings after OWASP, test-coverage, design-contract, and retry-integrity review. Unresolved HIGH findings: 0. |
+| 5 | Final branch is clean | **PASS** | `git status --porcelain=v1` was empty after all exact-SHA validation. `git diff --check origin/main...HEAD` produced no output. The configured hook path is active at `/home/jaword/projects/gascity/.githooks`; the gate commit runs the pre-commit hook. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first after fetching main. `git merge-tree --write-tree origin/main 8137a6d6e73513336c12d3cb9815b185ff4a1773` exited 0 and produced tree `3db85acd35f38148ef728a68dbcf178fd9f31899`; no content conflicts. The candidate is 15 commits behind / 2 ahead of current main, and no self-rebase or source-branch mutation was required. |
+| 7 | Single feature theme | **PASS** | The commit set is exactly the explicitly reviewed reliability bundle: route unassigned demand to the existing named-session holder without a redundant standby, and keep the ownership guard reliable under transient Dolt read contention while delivering that change. There are no additional source-branch commits or unrelated product surfaces. |
+
+## Acceptance evidence
+
+### Named-session routed demand
+
+- `TestCanonicalSingletonAliasHeldTemplates_AsleepNamedHolderStillHoldsAlias`
+- `TestCanonicalSingletonAliasHeldTemplates_AsleepNamedHolderIdentityDiffersFromTemplate`
+- `TestComputePoolDesiredStates_AsleepNamedHolderSuppressesRedundantStandby`
+- `TestReconcileSessionBeads_OnDemandNamedSessionWakesFromPoolDemandWithoutNamedDemand`
+- `TestReconcileSessionBeads_OnDemandNamedSessionWakesFromSingletonPoolDemandWithoutNamedDemand`
+- `TestReconcileSessionBeads_AsleepNamedSingletonRegressionWakesInsteadOfStandby`
+
+All six passed with `-count=1`.
+
+#### Added by the maintainer fixups
+
+- `TestAwakeSetToWakeEvalsMapsRoutedDemandToWakeWork` — `"routed-demand"` maps to
+ `WakeWork`, not the `WakeConfig` default fallthrough.
+- `TestReconcilerWakeDemandOverridesSleepSuppressionForRoutedDemand` — the holder
+ wakes under a non-interactive sleep policy when alias suppression has zeroed
+ `poolDesired`, and explicit sleep intent still overrides.
+- `TestBuildDesiredState_RoutedDemandWakesOnlyCanonicalSingletonNamedSessions` —
+ a multi-instance backing pool does not emit the wake signal, while routed
+ demand still reaches ordinary pool sizing; the singleton control still does.
+
+Each was confirmed to **fail** with its production change reverted and the test
+left in place, so all three pin real behavior rather than passing vacuously.
+
+### Push ownership guard
+
+- A transient failed read recovers and permits the push.
+- Persistent read failure exhausts exactly three attempts and still blocks.
+- Recovery followed by a real ownership change still blocks.
+- Both guarded read sites use the bounded retry helper.
+- Retry guidance precedes the last-resort `--no-verify` text.
+
+The shell suite passed `26/26`, and its Go wrapper passed.
diff --git a/release-gates/ga-u7f149-rotation-conformance-timeout-gate.md b/release-gates/ga-u7f149-rotation-conformance-timeout-gate.md
new file mode 100644
index 0000000000..136b206b67
--- /dev/null
+++ b/release-gates/ga-u7f149-rotation-conformance-timeout-gate.md
@@ -0,0 +1,51 @@
+# Release Gate: rotation conformance per-read timeout
+
+Status: PASS
+
+Deploy bead: `ga-u7f149`
+Source bead: `ga-mllb6t`
+Review bead: `ga-7y3hku`
+Reviewed commit: `e26a24c1868d057d615cb5533fbed3dc97e10e9a`
+Planned deploy branch: `deploy/ga-u7f149-gate`
+Base evaluated: `origin/main` at `7a5bdeeee5c240663964916cea4c8f72dd91c1f4`
+
+`docs/PROJECT_MANIFEST.md` is not present in this checkout, so this gate uses
+the deployer role's release criteria and the repository testing policy in
+`TESTING.md`.
+
+## Criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 6 | Branch diverges cleanly from main | PASS | Evaluated first. The reviewed branch's remote tip exactly matched the recorded SHA. `git merge-tree --write-tree origin/main e26a24c1868d057d615cb5533fbed3dc97e10e9a` exited 0 and produced tree `b552224ff6f5d068933d28bc0e395da972430397`. |
+| 1 | Review PASS present | PASS | Review bead `ga-7y3hku` is closed with verdict PASS for `builder/ga-c1r8af` at the reviewed commit. Its style, security, and specification checks all report no blocking findings. |
+| 2 | Acceptance criteria met | PASS | `RunRotationTests` now uses `context.WithCancel` for watcher lifetime and routes every blocking read through `nextWithin(testutil.GoroutineRaceTimeout)`. No `context.WithTimeout`, direct loop-level `w.Next`, or `10*time.Second` literal remains in the function. The file is gofmt-clean, the focused conformance test passed 20 repetitions, the independent exec consumer passed, and a freshly built stress binary completed 600/600 rotation-invariant runs without failure. |
+| 3 | Tests pass | PASS | `go test ./internal/events/... -run TestFileRecorderConformance -count=20`; `go test ./internal/events/exec/... -count=1`; 24 workers × 25 runs of `TestFileRecorderConformance/RotationPreservesInvariants` from a freshly built binary (600 runs, 0 failures); `make test-fast-parallel` (all 9 jobs passed); and `go vet ./...` all passed. |
+| 4 | No high-severity review findings open | PASS | Review notes report no style or security findings and no blocking issue; unresolved HIGH findings: 0. |
+| 5 | Final branch is clean | PASS | Before creating this checklist, `git status --porcelain=v1` returned no entries at the exact reviewed commit. The checklist is committed separately as the deploy-branch tip. |
+| 7 | Single feature theme | PASS | The reviewed commit changes only `internal/events/eventstest/conformance.go`, within one test-harness subsystem, to replace a shared rotation deadline with per-read deadlines. |
+
+## Acceptance Evidence
+
+- The watcher remains explicitly bounded by the existing deferred `cancel` and
+ `Close` calls, while rotation I/O no longer consumes the read deadline.
+- Both the pre-rotation drain and post-rotation read loop call the same local
+ `nextWithin` helper with the repository's centralized goroutine-race timeout.
+- The helper uses a buffered result channel, matching the existing per-read
+ timeout idiom in this conformance package without introducing a new
+ production abstraction.
+- Production event recording and watcher code are unchanged.
+
+## Commands
+
+```text
+git ls-remote origin refs/heads/builder/ga-c1r8af
+git merge-tree --write-tree origin/main e26a24c1868d057d615cb5533fbed3dc97e10e9a
+gofmt -l internal/events/eventstest/conformance.go
+git diff --check e26a24c1868d057d615cb5533fbed3dc97e10e9a^
+go test ./internal/events/... -run TestFileRecorderConformance -count=20
+go test ./internal/events/exec/... -count=1
+go test -c ./internal/events
+make test-fast-parallel
+go vet ./...
+```
diff --git a/release-gates/ga-vn396k-doctor-custom-types-home-isolation-gate.md b/release-gates/ga-vn396k-doctor-custom-types-home-isolation-gate.md
new file mode 100644
index 0000000000..430cec6096
--- /dev/null
+++ b/release-gates/ga-vn396k-doctor-custom-types-home-isolation-gate.md
@@ -0,0 +1,82 @@
+# Release gate: doctor custom-types HOME isolation
+
+- Deploy bead: `ga-vn396k`
+- Build bead: `ga-8pkpor`
+- Review bead: `ga-88chom`
+- Reviewed source: `e939c519073c6d95f515fb197889d2a7a4628591`
+- Gate base: `origin/main@2c3b6d94835b201b839b32d3bc5f219f72e0e6ac`
+- Feature merge base: `690675170a1a8b21afb61acb29e5f750a499d530`
+- Evaluation date: 2026-07-31
+- Disposition: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present at the reviewed commit. This
+checklist applies the deployer role's release criteria, `TESTING.md`, and the
+test-evidence requirements in
+`engdocs/contributors/release-gate-criteria-conventions.md`.
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after testing. `git merge-tree --write-tree origin/main e939c519073c6d95f515fb197889d2a7a4628591` exited 0 against `origin/main@2c3b6d94835b201b839b32d3bc5f219f72e0e6ac` and produced tree `6ac407c0ce8729a4d96f37384032834f5de91489`. The reviewed source is four commits ahead and two behind current main with no content conflict; no self-rebase was needed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-88chom` records `REVIEWER VERDICT: PASS` for exact source `e939c519073c6d95f515fb197889d2a7a4628591`. The deploy bead repeats the reviewed SHA and PASS handoff. |
+| 2 | Acceptance criteria met | **PASS** | `TestCustomTypesCheck_TableDrift` pins a `t.TempDir` HOME before its first `bd` subprocess; both custom-types fixtures scrub the three shared-server environment selectors; and the new regression proves that HOME has no `.beads/config.yaml`, metadata selects embedded Dolt with a non-empty database, and command output contains no shared-server routing text. The exact `HOME=/home/jaword` feature smoke passed **7 PASS, 0 FAIL, 0 SKIP**. The resource-census mirror check passed **1 PASS, 0 FAIL, 0 SKIP**. The live server remained PID 142645 on port 3308, and its database list was byte-identical before and after: `beads_global`, `dolt`, `information_schema`, `mysql`. No operator config or shared-server database was modified. |
+| 3 | Tests pass | **PASS** | `go build ./...` and `go vet ./...` passed. The documented fast CI baseline, `make test-fast-parallel`, reported **10 jobs PASS, 0 FAIL, 0 job-level SKIP**. Because this diff touches `internal/**`, the path-required `make test-cmd-gc-process-parallel` lane was also run with `GC_FAST_UNIT=0`: it selected 8,200 top-level tests across six shards plus the six-test product-metrics job and reported **4 jobs PASS, 3 FAIL, 0 job-level SKIP**. `TestTutorial01` was selected in passing shard 1. The only three failure markers were the already-documented pool/scale-check ambient-HOME set. A focused differential under `HOME=/home/jaword` produced the identical **0 PASS, 3 FAIL, 0 SKIP** set at both merge base and reviewed SHA, including `database "beads" not found ... 127.0.0.1:3308`; with an empty HOME, the same reviewed test binary passed those tests **3 PASS, 0 FAIL, 0 SKIP**. The red shard result is retained as diagnostic evidence, not relabeled green: the unchanged base/tip failure set plus the clean-HOME pass establishes that the reviewed diff adds no regression and matches the clean CI runner condition. |
+| 4 | No high-severity review findings open | **PASS** | The reviewer found no security, correctness, compatibility, scope, or blocking issue. The sole non-blocking note suggests future consolidation of the cleanup-retry helper. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this checklist, `git status --porcelain=v1 --untracked-files=all` produced no output and `git diff --check origin/main...e939c519073c6d95f515fb197889d2a7a4628591` exited 0. The checklist is the sole deployer-authored source change and will be committed before push. `core.hooksPath` is `.githooks`. |
+| 7 | Single feature theme | **PASS** | The four TDD commits change one adjacent doctor-test isolation path plus its mechanically required resource-census mirrors. All four files serve the same behavior: prevent machine-level Dolt shared-server configuration from influencing custom-types tests. No independent feature is bundled. |
+
+## Test evidence
+
+```text
+make test-fast-parallel
+10 jobs PASS, 0 FAIL, 0 job-level SKIP
+
+make test-cmd-gc-process-parallel
+4 jobs PASS, 3 FAIL, 0 job-level SKIP
+8,200 selected top-level tests across six GC_FAST_UNIT=0 shards
+productmetrics-testhook: PASS (6 selected tests)
+TestTutorial01: selected in passing shard 1
+
+Only failure markers:
+TestBuildDesiredState_MinZeroDefaultScaleCheckRoutedWorkCreatesPoolSession
+TestEvaluatePoolDefaultScaleCheckCountsRoutedReadyWork
+TestEvaluatePoolDefaultScaleCheckIgnoresRoutedActiveUnassignedWork
+
+Focused differential, HOME=/home/jaword:
+merge base 690675170: 0 PASS, 3 FAIL, 0 SKIP
+reviewed e939c5190: 0 PASS, 3 FAIL, 0 SKIP
+identical failure names and 127.0.0.1:3308 signature
+
+Reviewed test binary, empty temporary HOME:
+3 PASS, 0 FAIL, 0 SKIP
+
+HOME=/home/jaword go test -json ./internal/doctor \
+ -run '^TestCustomTypesCheck' -count=1
+7 PASS, 0 FAIL, 0 SKIP
+
+go test -json ./internal/testpolicy/resourcecensus \
+ -run '^TestRepositoryLedgerMatchesCensusAndDocumentation$' -count=1
+1 PASS, 0 FAIL, 0 SKIP
+
+go build ./...
+PASS
+
+go vet ./...
+PASS
+```
+
+The process-shard runner reports job outcomes and selected top-level counts,
+not per-test skip totals. No job was skipped. The focused feature, census, and
+environment-differential runs used JSON or verbose terminal events and had
+zero skips.
+
+## Scope evidence
+
+```text
+TESTING.md | 11 +--
+internal/doctor/checks_custom_types_test.go | 108 ++++++++++++++++++++++++++-
+internal/testpolicy/resourcecensus/census.go | 27 +++++--
+test/test-resources.toml | 27 +++++--
+4 files changed, 150 insertions(+), 23 deletions(-)
+```
diff --git a/release-gates/ga-x86bjw-precommit-openapi-npm-fail-closed-gate.md b/release-gates/ga-x86bjw-precommit-openapi-npm-fail-closed-gate.md
new file mode 100644
index 0000000000..03f5c0a1eb
--- /dev/null
+++ b/release-gates/ga-x86bjw-precommit-openapi-npm-fail-closed-gate.md
@@ -0,0 +1,40 @@
+# Release Gate: Pre-commit OpenAPI/npm fail-closed behavior
+
+- Deploy bead: `ga-x86bjw`
+- Source review: `ga-jg89a5`
+- Reviewed commit: `9600c301cc85581fe52b0c476c92aeac9f5d651e`
+- Candidate base: `f68a2ed019a21d9efc41ed1d02c9233eeb8463de`
+- Main evaluated: `origin/main@a72480ec884e5f6369f23b84cb18786affa49df5`
+- Deploy branch: `deploy/ga-x86bjw-gate`
+- Evaluated: `2026-07-28T05:05:30Z`
+- Overall verdict: **PASS**
+
+`docs/PROJECT_MANIFEST.md` is not present in this repository at the evaluated
+commit, so this checklist applies the deployer role's release-gate criteria.
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 6 | Branch diverges cleanly from main | **PASS** | Checked first after fetching `origin/main`. `git merge-tree --write-tree origin/main 9600c301cc85581fe52b0c476c92aeac9f5d651e` exited 0 and produced tree `b5b736b0de846d01868ff8815338659ea532fc90`. No self-rebase or source-branch mutation was needed. |
+| 1 | Review PASS present | **PASS** | Review bead `ga-jg89a5` records the earlier request-changes verdict for `d6dd43a87`, followed by an independent re-review with `REVIEW VERDICT: PASS (re-review of rework)` and `FINAL VERDICT: PASS` for exact commit `9600c301cc85581fe52b0c476c92aeac9f5d651e`. |
+| 2 | Acceptance criteria met | **PASS** | The pre-commit hook now re-reads staged `internal/api/openapi.json` after its Go generation block and shares that fresh result between both npm branches. With npm absent, a directly staged spec or a spec staged as the Go block's side effect fails closed with the recovery command; unrelated changes remain warn-only. End-to-end contract tests cover both fail-closed paths and the warning boundary. Contributor guidance now points to the current dashboard path and `make dashboard-ci`. The three resource-ledger counters each rise by exactly six, matching the six new `exec.Command` call sites (five → eleven in `scripts/precommit_contract_test.go`). |
+| 3 | Tests pass | **PASS** | First-attempt checks on the exact reviewed SHA passed: `gofmt -l` was empty; `bash -n .githooks/pre-commit` passed; five focused hook contracts passed; full `go test ./scripts/...`, `go test ./internal/testpolicy/resourcecensus/...`, and `go test ./test/docsync/...` passed; `go build ./...` and `go vet ./...` passed; `make test-fast-parallel` passed all nine jobs. |
+| 4 | No high-severity review findings open | **PASS** | The prior blocking finding was fixed and independently RED/GREEN verified during re-review. The final exact-SHA review reports no security findings, no coverage gaps, and no blockers. Unresolved HIGH/CRITICAL findings: 0. |
+| 5 | Final branch is clean | **PASS** | Before adding this checklist, detached `9600c301c` had an empty `git status --porcelain=v1`; `git diff --check` against its merge base passed. The configured hook path is `.githooks`; this checklist is the only deployer-authored release commit. |
+| 7 | Single feature theme | **PASS** | The two reviewed commits and nine touched files form one contributor-safety change: prevent stale generated dashboard clients when OpenAPI changes cannot be regenerated locally, pin the behavior in hook-contract tests, update its resource ledger, and correct the matching contributor instructions. No independent product feature is bundled. |
+
+## Commands
+
+```bash
+git fetch origin main
+git merge-tree --write-tree origin/main 9600c301cc85581fe52b0c476c92aeac9f5d651e
+git diff --check f68a2ed019a21d9efc41ed1d02c9233eeb8463de..9600c301cc85581fe52b0c476c92aeac9f5d651e
+gofmt -l scripts/precommit_contract_test.go internal/testpolicy/resourcecensus/census.go
+bash -n .githooks/pre-commit
+go test ./scripts/... -run 'TestPreCommitFailsClosedWhenGoBlockStagesSpecAsSideEffectAndNpmAbsent|TestPreCommitFailsClosedWhenSpecStagedButNpmAbsent|TestPreCommitWarnsOnlyWhenNpmAbsentAndSpecNotStaged|TestPreCommitRegeneratesDashboardClientOnSpecChange|TestPreCommitReachesDashboardBlockWhenOnlySpecFileStaged' -count=1 -v
+go test ./scripts/... -count=1
+go test ./internal/testpolicy/resourcecensus/... -count=1
+go test ./test/docsync/... -count=1
+go build ./...
+go vet ./...
+make test-fast-parallel
+```
diff --git a/release-gates/ga-yg3x8u-resourcecensus-ledger-generator-gate.md b/release-gates/ga-yg3x8u-resourcecensus-ledger-generator-gate.md
new file mode 100644
index 0000000000..bb8883dcca
--- /dev/null
+++ b/release-gates/ga-yg3x8u-resourcecensus-ledger-generator-gate.md
@@ -0,0 +1,60 @@
+# Release Gate: resource-census TESTING.md ledger generator
+
+Bead: ga-yg3x8u
+Source review bead: ga-ffmf9m
+Build bead: ga-cwfzvz
+Reviewed commit: 57ff991178fd2a0a788591cb5e86651ee476af28
+Gate date: 2026-07-28
+
+## Summary
+
+PASS. The resource-census package now exposes a checked-markdown block
+replacement helper and gives
+`TestRepositoryLedgerMatchesCensusAndDocumentation` an `-update` mode. The
+failure message names the exact regeneration command, and regeneration
+replaces only the marked ledger block while preserving the rest of
+`TESTING.md` byte-for-byte.
+
+`docs/PROJECT_MANIFEST.md` is not present on the reviewed commit or current
+`origin/main`; this gate uses the deployer role release criteria and the
+canonical repository guidance in `TESTING.md`.
+
+## Criteria
+
+| # | Criterion | Result | Evidence |
+|---|-----------|--------|----------|
+| 1 | Review PASS present | PASS | Review bead ga-ffmf9m is closed with close reason `pass`; its notes record `verdict: pass`, no style or security findings, and independent acceptance verification at reviewed commit 57ff991178fd2a0a788591cb5e86651ee476af28. |
+| 2 | Acceptance criteria met | PASS | All four ga-cwfzvz acceptance criteria were checked against the reviewed code and reproduced locally. The focused acceptance suite passed 5 tests with 0 failures and 0 skips. A deliberate one-line corruption of the checked `TESTING.md` block failed with the exact documented `-update` command; running that command passed and restored the original Git blob exactly. `TestGeneratedLedgerBlockRoundTrips` proves generated content round-trips while surrounding content remains unchanged. |
+| 3 | Tests pass | PASS | Documented CI-equivalent command `make test-fast-parallel`: 10 PASS, 0 FAIL, 0 SKIP jobs. Focused acceptance command: 5 PASS, 0 FAIL, 0 SKIP tests. `go vet ./...` passed. No skip justification is required because both recorded runs had zero skips. |
+| 4 | No high-severity review findings open | PASS | Reviewer notes report no style findings, no security findings, no blockers, and no uncovered criteria. Unresolved HIGH findings: 0. |
+| 5 | Final branch is clean | PASS | The reviewed commit was checked out detached with an empty `git status --short`; the deliberate acceptance-test corruption was repaired by the generator back to the exact original blob before the full suite. The gate artifact is the only deployer-added file and will be committed on the isolated deploy branch. |
+| 6 | Branch diverges cleanly from main | PASS | Evaluated first as required. `git merge-tree --write-tree origin/main 57ff991178fd2a0a788591cb5e86651ee476af28` exited 0 against `origin/main@1c8573165a5e8d52146ca7cdbf4b9d9b4429b731` and produced tree `d0c51d4410a1ac020236d2912cba0d803179fbca`; no self-rebase was needed. |
+| 7 | Single feature theme | PASS | The commit changes only `internal/testpolicy/resourcecensus/census.go` and its adjacent test file. Both changes implement and prove one behavior: deterministic regeneration of the checked TESTING.md resource ledger. |
+
+## Acceptance Evidence
+
+1. A single command is documented by the test flag comment and surfaced in
+ the stale-ledger diagnostic:
+ `go test ./internal/testpolicy/resourcecensus -run TestRepositoryLedgerMatchesCensusAndDocumentation -update`.
+2. After changing the checked ledger's subprocess count from 541 to 999, the
+ non-update test failed and printed that exact command.
+3. Running the command alone made the test pass and restored `TESTING.md` from
+ modified content to its original blob
+ `c5aad29fedf6c1880c42c14457638acb010c6fbe`, with no hand edit.
+4. `TestGeneratedLedgerBlockRoundTrips` passed and verifies both generated
+ block equality and preservation of surrounding documentation.
+
+## Test Evidence
+
+| Command | Counts | Result |
+|---------|--------|--------|
+| `go test -count=1 -v ./internal/testpolicy/resourcecensus/... -run 'TestReplaceMarkdownBlockRoundTrips\|TestGeneratedLedgerBlockRoundTrips\|TestReplaceMarkdownBlockRequiresOneOrderedMarkerPair\|TestRepositoryLedgerMatchesCensusAndDocumentation\|TestCheckedMarkdownBlock'` | 5 PASS, 0 FAIL, 0 SKIP tests | PASS |
+| Deliberate stale-ledger run: `go test -count=1 ./internal/testpolicy/resourcecensus -run TestRepositoryLedgerMatchesCensusAndDocumentation` | 0 PASS, 1 expected FAIL, 0 SKIP tests | Expected RED; diagnostic named the regeneration command |
+| Repair run: `go test ./internal/testpolicy/resourcecensus -run TestRepositoryLedgerMatchesCensusAndDocumentation -update` | 1 PASS, 0 FAIL, 0 SKIP tests | PASS; original and regenerated Git blob IDs matched |
+| `make test-fast-parallel` | 10 PASS, 0 FAIL, 0 SKIP jobs | PASS |
+| `go vet ./...` | Not a test-counting command | PASS |
+
+## Final Gate Result
+
+PASS. The reviewed commit is suitable for an isolated deploy branch, pull
+request, and merge-authority handoff.
diff --git a/release-gates/mac-regression-centralized-gate.md b/release-gates/mac-regression-centralized-gate.md
new file mode 100644
index 0000000000..23275040d1
--- /dev/null
+++ b/release-gates/mac-regression-centralized-gate.md
@@ -0,0 +1,30 @@
+# Release gate: centralized macOS regression routing
+
+- Deploy bead: `ga-dvo3mn`
+- Build bead: `ga-n7ef4e`
+- Review bead: `ga-99n5nd`
+- Reviewed source: `c5ff3129389ff708a8c4899567b1d48e41b8403f`
+- Gate base: `origin/main@e6135a435098a70f20081d1d88a03b6742002d9a`
+- Evaluation date: 2026-07-30
+- Disposition: **PASS**
+
+## Gate checklist
+
+| # | Criterion | Result | Evidence |
+|---|---|---|---|
+| 1 | Review PASS present | **PASS** | Independent review bead `ga-99n5nd` records round-2 `verdict: pass` at the reviewed source SHA after the builder addressed all three round-1 findings. |
+| 2 | Acceptance criteria met | **PASS** | The workflow has one always-run `gate` job that emits the three tier booleans and a reason; all tier jobs consume those outputs; the summary uses bare `always()` and reads the gate result. The corrected checkout has explicit repository/ref and `persist-credentials: false`, the diagnostic path filter contains exactly `cmd/gc/**`, `internal/pathutil/**`, and `internal/fsys/**`, and unknown manual-dispatch suites fall back to smoke. Dedicated Go contract tests cover each invariant. |
+| 3 | Tests pass | **PASS** | At the reviewed source SHA: `go build ./...` and `go vet ./...` passed; `go test ./scripts/... -count=1 -v` reported 351 PASS, 0 FAIL, 0 SKIP; `make test-fast-parallel` passed all 10 jobs; and `make test-cmd-gc-process-parallel` passed all six `GC_FAST_UNIT=0` shards plus `productmetrics-testhook`, reporting 15,243 PASS, 0 FAIL, and 11 intentional skips. The process skips are existing helper-only, opt-in live-canary, unsupported-OS, unavailable optional prompt-fixture, or ambient-cwd cases explicitly disabled inside test binaries; none bears on workflow routing. Nine additional required CI outputs induced by the workflow-file/shared-OR path filters were not locally re-run: six have no file overlap with this diff, two worker/integration surfaces have no code overlap, and one is Windows-only. GitHub CI remains the authoritative execution of those checks before merge. |
+| 4 | No high-severity review findings open | **PASS** | Round 2 reports no blocking security, style, or specification findings; all three prior findings are fixed and directly tested. Unresolved HIGH count is 0. |
+| 5 | Final branch is clean | **PASS** | `git status --porcelain` was empty after testing at the reviewed source SHA; only this gate record was then added. |
+| 6 | Branch diverges cleanly from main | **PASS** | Evaluated first and rechecked after tests. `git merge-tree --write-tree origin/main c5ff3129389ff708a8c4899567b1d48e41b8403f` exited 0 and produced tree `5abfc57c279b35786da86938d816602e04940c9e`; no self-rebase was required. |
+| 7 | Single feature theme | **PASS** | The reviewed three-commit set changes one GitHub Actions workflow and its contract test file to centralize macOS regression tier routing and make skipped-workflow outcomes visible. |
+
+## Acceptance evidence
+
+- Scheduled runs enable smoke, full, and review-formula tiers.
+- Manual `full`, `needs-mac`, `smoke`, and unknown suite values route deterministically, with unknown values retaining smoke coverage.
+- Same-repository, non-draft pull requests require the `needs-mac` label; fork and draft pull requests remain skipped with explicit reasons.
+- Every tier job reads the centralized gate outputs rather than duplicating trigger expressions.
+- The summary always runs and fails closed when the gate or any selected tier fails.
+- No new permissions, dependencies, action versions, secrets, or trigger events are introduced.
diff --git a/schemas/metrics/example/result.schema.json b/schemas/metrics/example/result.schema.json
index 0a886d9c57..8e1d4337d1 100644
--- a/schemas/metrics/example/result.schema.json
+++ b/schemas/metrics/example/result.schema.json
@@ -206,10 +206,12 @@
"logout",
"whoami",
"runtime-heartbeat",
- "provider-rotate-key",
+ "pack-registry-requests",
+ "events-reemit-execution",
"beads-state",
"config-lint",
- "provider-quota"
+ "provider-quota",
+ "provider-rotate-key"
]
},
"event_id": {
diff --git a/schemas/pack/registry/requests/result.schema.json b/schemas/pack/registry/requests/result.schema.json
new file mode 100644
index 0000000000..6930237773
--- /dev/null
+++ b/schemas/pack/registry/requests/result.schema.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "description": "Registry API response emitted by gc pack registry requests --json.",
+ "x-gc-raw-json": true,
+ "oneOf": [{"$ref": "#/$defs/listResponse"}, {"$ref": "#/$defs/detailResponse"}],
+ "$defs": {
+ "summary": {
+ "type": "object",
+ "description": "Registry-owned submitter publish-request summary.",
+ "required": ["id", "status", "nextStep", "requestedName", "requestedVersion", "unread"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1},
+ "status": {"type": "string", "minLength": 1},
+ "nextStep": {"type": "string", "minLength": 1},
+ "requestedName": {"type": "string"},
+ "requestedVersion": {"type": "string"},
+ "unread": {"type": "boolean"},
+ "actionRequiredBy": {"type": "string"},
+ "submitterUnreadAt": {"type": "string", "format": "date-time"}
+ },
+ "additionalProperties": true
+ },
+ "comment": {
+ "type": "object",
+ "description": "Registry feedback comment.",
+ "required": ["id", "authorHandle", "authorRole", "body", "createdAt"],
+ "properties": {
+ "id": {"type": "string", "minLength": 1},
+ "authorHandle": {"type": "string"},
+ "authorRole": {"type": "string"},
+ "body": {"type": "string"},
+ "createdAt": {"type": "string", "format": "date-time"}
+ },
+ "additionalProperties": true
+ },
+ "listResponse": {
+ "type": "object",
+ "description": "Recent publish requests owned by the authenticated submitter.",
+ "required": ["publishRequests", "unreadCount"],
+ "properties": {
+ "publishRequests": {"type": "array", "items": {"$ref": "#/$defs/summary"}},
+ "unreadCount": {"type": "integer", "minimum": 0}
+ },
+ "additionalProperties": true
+ },
+ "detailResponse": {
+ "type": "object",
+ "description": "One owned publish request and its Registry feedback comments.",
+ "required": ["publishRequest"],
+ "properties": {
+ "publishRequest": {
+ "allOf": [
+ {"$ref": "#/$defs/summary"},
+ {
+ "type": "object",
+ "required": ["comments"],
+ "properties": {"comments": {"type": "array", "items": {"$ref": "#/$defs/comment"}}},
+ "additionalProperties": true
+ }
+ ]
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/scripts/check-routed-test-rows.sh b/scripts/check-routed-test-rows.sh
index dd0d3ba3cc..fe4c427d71 100755
--- a/scripts/check-routed-test-rows.sh
+++ b/scripts/check-routed-test-rows.sh
@@ -113,7 +113,7 @@ if (( violations > 0 )); then
echo "---"
echo "Six-row matrix violations: $violations"
echo "A matrix test file MUST contain all six rows and be listed in scripts/routed-test-rows.manifest."
- echo "See docs/plans/ga-h6w-read-path-api-routing.md."
+ echo "See the six-row matrix definition in this script's header comment (bead ga-h6w)."
exit 1
fi
diff --git a/scripts/ci_critical_path_test.go b/scripts/ci_critical_path_test.go
index ade5b139ea..d14746d602 100644
--- a/scripts/ci_critical_path_test.go
+++ b/scripts/ci_critical_path_test.go
@@ -22,6 +22,7 @@ type ciCriticalPathJob struct {
If string `yaml:"if"`
RunsOn string `yaml:"runs-on"`
Needs ciCriticalPathNeeds `yaml:"needs"`
+ Outputs map[string]string `yaml:"outputs"`
Steps []ciCriticalPathStep `yaml:"steps"`
Strategy ciCriticalPathJobStrategy `yaml:"strategy"`
ContinueOnError bool `yaml:"continue-on-error"`
@@ -593,6 +594,250 @@ func TestMacAcceptanceRetainsExternalBdContract(t *testing.T) {
}
}
+// TestMacRegressionGateCentralizesTierRouting asserts the new centralized
+// `gate` job (ga-hd99jq D1 / ga-n7ef4e): it always runs (no `if:`, so an
+// all-skipped workflow run can no longer happen), computes one reason string
+// plus the three tier booleans other jobs key off of, and mirrors
+// review-formulas.yml's own paths-filter + decision-step shape.
+func TestMacRegressionGateCentralizesTierRouting(t *testing.T) {
+ wf := readCriticalPathWorkflow(t, "mac-regression.yml")
+
+ gate, ok := wf.Jobs["gate"]
+ if !ok {
+ t.Fatal("mac-regression workflow has no centralized gate job")
+ }
+ if !slices.Contains(gate.Needs, "runner-policy") {
+ t.Errorf("gate needs = %v, want runner-policy", gate.Needs)
+ }
+ if strings.TrimSpace(gate.If) != "" {
+ t.Errorf("gate if = %q, want no condition — the gate itself must always run so every tier job and the summary can read its outputs", strings.TrimSpace(gate.If))
+ }
+ const gateRunner = "${{ needs.runner-policy.outputs.runner_2vcpu }}"
+ if gate.RunsOn != gateRunner {
+ t.Errorf("gate runs-on = %q, want %q (routing decision is cheap, keep it off macOS runners)", gate.RunsOn, gateRunner)
+ }
+
+ wantOutputs := map[string]string{
+ "run_smoke": "${{ steps.gate.outputs.run_smoke }}",
+ "run_full": "${{ steps.gate.outputs.run_full }}",
+ "run_review_formulas": "${{ steps.gate.outputs.run_review_formulas }}",
+ "reason": "${{ steps.gate.outputs.reason }}",
+ }
+ for name, want := range wantOutputs {
+ if got := gate.Outputs[name]; got != want {
+ t.Errorf("gate output %s = %q, want %q", name, got, want)
+ }
+ }
+
+ var filterStep, decideStep, checkoutStep ciCriticalPathStep
+ var hasFilter, hasDecide, hasCheckout bool
+ for _, step := range gate.Steps {
+ if strings.HasPrefix(step.Uses, "actions/checkout@") {
+ checkoutStep, hasCheckout = step, true
+ }
+ switch step.ID {
+ case "filter":
+ filterStep, hasFilter = step, true
+ case "gate":
+ decideStep, hasDecide = step, true
+ }
+ }
+ if !hasCheckout {
+ t.Fatal("gate job has no checkout step")
+ }
+ wantCheckoutWith := map[string]string{
+ "repository": "${{ inputs.head_repo || github.repository }}",
+ "ref": "${{ inputs.head_sha || github.sha }}",
+ "persist-credentials": "false",
+ }
+ for name, want := range wantCheckoutWith {
+ if got := checkoutStep.With[name]; got != want {
+ t.Errorf("gate checkout with.%s = %q, want %q (every other mac-regression job pins the same head ref/repo and disables credential persistence; the gate job must not be the odd one out)", name, got, want)
+ }
+ }
+ if !hasFilter {
+ t.Fatal("gate job has no paths-filter step (id: filter)")
+ }
+ if !strings.Contains(filterStep.Uses, "dorny/paths-filter") {
+ t.Errorf("gate filter step uses = %q, want dorny/paths-filter (same tool review-formulas.yml uses)", filterStep.Uses)
+ }
+ wantFilterEntries := []string{"cmd/gc/**", "internal/pathutil/**", "internal/fsys/**"}
+ filterValue := filterStep.With["filters"]
+ for _, entry := range wantFilterEntries {
+ if !strings.Contains(filterValue, "'"+entry+"'") {
+ t.Errorf("gate filter mac_sensitive list missing %q; want exactly %v", entry, wantFilterEntries)
+ }
+ }
+ if gotEntries := regexp.MustCompile(`(?m)^\s*-\s*'[^']*'\s*$`).FindAllString(filterValue, -1); len(gotEntries) != len(wantFilterEntries) {
+ t.Errorf("gate filter mac_sensitive has %d path entries, want exactly %d (%v) — no broader glob than the paths that actually touch gc/cmd or fsys/pathutil behavior", len(gotEntries), len(wantFilterEntries), wantFilterEntries)
+ }
+ if !hasDecide {
+ t.Fatal("gate job has no routing-decision step (id: gate)")
+ }
+
+ wantDecideEnv := map[string]string{
+ "EVENT_NAME": "${{ github.event_name }}",
+ "SUITE_INPUT": "${{ inputs.suite }}",
+ "PR_HEAD_REPO": "${{ github.event.pull_request.head.repo.full_name }}",
+ "PR_DRAFT": "${{ github.event.pull_request.draft }}",
+ "NEEDS_LABEL": "${{ contains(github.event.pull_request.labels.*.name, 'needs-mac') }}",
+ "PATH_HIT": "${{ steps.filter.outputs.mac_sensitive }}",
+ }
+ for name, want := range wantDecideEnv {
+ if got := decideStep.Env[name]; got != want {
+ t.Errorf("gate decision step env %s = %q, want %q", name, got, want)
+ }
+ }
+
+ // Every trigger path the exit_contract enumerates must be handled so the
+ // refactor preserves today's per-job run/skip outcome exactly.
+ for _, marker := range []string{
+ `"$EVENT_NAME" == "schedule"`,
+ `run_smoke=true; run_full=true; run_review_formulas=true`,
+ `"$EVENT_NAME" == "workflow_dispatch"`,
+ `case "$SUITE_INPUT" in`,
+ `needs-mac)`,
+ `"$EVENT_NAME" == "pull_request"`,
+ `"$PR_HEAD_REPO" != "${{ github.repository }}"`,
+ `"$PR_DRAFT" == "true"`,
+ `"$NEEDS_LABEL" == "true"`,
+ `echo "run_smoke=$run_smoke"`,
+ `echo "run_full=$run_full"`,
+ `echo "run_review_formulas=$run_review_formulas"`,
+ `echo "reason=$reason"`,
+ } {
+ if !strings.Contains(decideStep.Run, marker) {
+ t.Errorf("gate decision step run script missing %q", marker)
+ }
+ }
+
+ // An unrecognized (or default) $SUITE_INPUT on a manual dispatch must
+ // still run the smoke tier, not silently run nothing — run_smoke must
+ // be set unconditionally before the case statement, not only inside
+ // specific case branches, so the catch-all `*)` arm inherits it too.
+ const dispatchMarker = `"$EVENT_NAME" == "workflow_dispatch" ]]; then`
+ dispatchIdx := strings.Index(decideStep.Run, dispatchMarker)
+ if dispatchIdx < 0 {
+ t.Fatal("gate decision step run script missing workflow_dispatch branch")
+ }
+ afterDispatch := decideStep.Run[dispatchIdx+len(dispatchMarker):]
+ caseIdx := strings.Index(afterDispatch, `case "$SUITE_INPUT" in`)
+ if caseIdx < 0 {
+ t.Fatal("gate decision step run script missing case statement in workflow_dispatch branch")
+ }
+ if preCase := afterDispatch[:caseIdx]; !strings.Contains(preCase, "run_smoke=true") {
+ t.Errorf("gate decision step workflow_dispatch branch does not set run_smoke=true before the case statement (preamble %q) — an unrecognized suite input must still default to the smoke tier", preCase)
+ }
+}
+
+// TestMacRegressionTierJobsGateOnCentralizedOutputs asserts every tier job
+// collapses its duplicated multi-line if: into a single check against the
+// gate job's own output (ga-hd99jq D1) — a refactor of how the decision is
+// computed, not a change to which jobs run when.
+func TestMacRegressionTierJobsGateOnCentralizedOutputs(t *testing.T) {
+ wf := readCriticalPathWorkflow(t, "mac-regression.yml")
+
+ tests := []struct {
+ job string
+ wantIf string
+ }{
+ {"mac-quality", "needs.gate.outputs.run_smoke == 'true'"},
+ {"mac-unit", "needs.gate.outputs.run_smoke == 'true'"},
+ {"mac-cmd-gc-process", "needs.gate.outputs.run_smoke == 'true'"},
+ {"mac-acceptance", "needs.gate.outputs.run_smoke == 'true'"},
+ {"mac-cover", "needs.gate.outputs.run_full == 'true'"},
+ {"mac-integration-packages", "needs.gate.outputs.run_full == 'true'"},
+ {"mac-integration-bdstore", "needs.gate.outputs.run_full == 'true'"},
+ {"mac-integration-rest", "needs.gate.outputs.run_full == 'true'"},
+ {"mac-integration-review-formulas", "needs.gate.outputs.run_review_formulas == 'true'"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.job, func(t *testing.T) {
+ job, ok := wf.Jobs[tt.job]
+ if !ok {
+ t.Fatalf("mac-regression workflow has no %s job", tt.job)
+ }
+ if !slices.Contains(job.Needs, "gate") {
+ t.Errorf("%s needs = %v, want gate", tt.job, job.Needs)
+ }
+ if got := strings.TrimSpace(job.If); got != tt.wantIf {
+ t.Errorf("%s if = %q, want exactly %q (single gate-output check, not a duplicated inline expression)", tt.job, got, tt.wantIf)
+ }
+ })
+ }
+}
+
+// TestMacRegressionSummaryAlwaysRunsAndReadsGateResult is the core
+// signal-integrity fix (ga-wecoe1, ga-hd99jq F1/F2): the summary job must
+// never itself be skipped, or an all-skipped run reports green. Its if:
+// becomes bare always(), and it must read the gate job's own result/outputs
+// rather than re-evaluating the trigger — the fleet's D5 rule.
+func TestMacRegressionSummaryAlwaysRunsAndReadsGateResult(t *testing.T) {
+ wf := readCriticalPathWorkflow(t, "mac-regression.yml")
+ job, ok := wf.Jobs["mac-regression-summary"]
+ if !ok {
+ t.Fatal("mac-regression workflow has no mac-regression-summary job")
+ }
+
+ if got := strings.TrimSpace(job.If); got != "always()" {
+ t.Errorf("mac-regression-summary if = %q, want bare always() so the summary itself is never skipped (D5: an all-skipped run must not report green)", got)
+ }
+ if !slices.Contains(job.Needs, "gate") {
+ t.Errorf("mac-regression-summary needs = %v, want gate", job.Needs)
+ }
+
+ var summarize ciCriticalPathStep
+ var found bool
+ for _, step := range job.Steps {
+ if step.Name == "Summarize" {
+ summarize, found = step, true
+ }
+ }
+ if !found {
+ t.Fatal("mac-regression-summary has no Summarize step")
+ }
+
+ wantEnv := map[string]string{
+ "GATE_RESULT": "${{ needs.gate.result }}",
+ "RUN_SMOKE": "${{ needs.gate.outputs.run_smoke }}",
+ "REASON": "${{ needs.gate.outputs.reason }}",
+ }
+ for name, want := range wantEnv {
+ if got := summarize.Env[name]; got != want {
+ t.Errorf("Summarize env %s = %q, want %q", name, got, want)
+ }
+ }
+
+ for _, marker := range []string{
+ `"${GATE_RESULT}" != "success"`,
+ `"${RUN_SMOKE}" != "true"`,
+ `Mac Regression: not requested`,
+ } {
+ if !strings.Contains(summarize.Run, marker) {
+ t.Errorf("Summarize run script missing %q (gate-failed / not-requested branch from the exit_contract)", marker)
+ }
+ }
+}
+
+// TestMacRegressionHeaderCommentDescribesCentralizedGate asserts the file
+// header (originally documenting "each job copies the expression; keep them
+// in sync") is updated to describe the centralized-gate shape that removes
+// that exact duplication.
+func TestMacRegressionHeaderCommentDescribesCentralizedGate(t *testing.T) {
+ path := filepath.Join(repoRoot(t), ".github", "workflows", "mac-regression.yml")
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ content := string(body)
+ if strings.Contains(content, "each job copies the") {
+ t.Error("mac-regression.yml header comment still describes the old per-job duplicated if: expression — update it to describe the centralized gate job")
+ }
+ if !strings.Contains(content, "gate") {
+ t.Error("mac-regression.yml header comment should describe the centralized gate job")
+ }
+}
+
func TestStaticChecksUseOnlyTheGoToolchain(t *testing.T) {
wf := readCriticalPathWorkflow(t, "ci.yml")
job := wf.Jobs["preflight-static"]
diff --git a/scripts/cipolicy/policy.go b/scripts/cipolicy/policy.go
index 53780d9262..8a14246141 100644
--- a/scripts/cipolicy/policy.go
+++ b/scripts/cipolicy/policy.go
@@ -25,11 +25,14 @@ const (
// auto-merged both sides' changes, so the resulting shape hashes to neither
// the fork's nor upstream's previous value — the correct action is to
// re-derive from the merged workflow rather than adopt either side's stale pin.
+ // Re-derived again at the v1.4.0 resync (ga-y708o follow-up): ci.yml
+ // auto-merged both sides, so the merged execution shape hashes to neither
+ // the fork's nor upstream's previous pin — re-derive, never adopt a side.
// Re-derived again for ga-kgluj: splitting BD_VERSION into BD_VERSION +
// BD_SOURCE_REF adds a job-level env key, and env is part of the execution
// shape this pin guards — so the tripwire firing here is correct behavior,
// not noise.
- expectedCIExecutionHash = "41d38414857d74ccb3d7faffe10deb0c27e92aaf87bfeafb94351bacf7ffe1b7"
+ expectedCIExecutionHash = "5c1e5f2198dbcdab3017f5543a5bee2041b1146fe2baacff17c38a2e31c08b21"
expectedNightlyTriggersHash = "0a4400a09ac567e90adf8be1232eef1f14e36efd8dba3e143aa6e36f5b7a36f5"
// Re-derived like the CI pin above. Note this one lands on the FORK's prior
// value: nightly.yml merged to the fork's execution shape, so wholesale
@@ -74,6 +77,7 @@ var requiredFilterPaths = map[string][]string{
"Makefile",
"internal/worker/**",
"internal/sessionlog/**",
+ "internal/modelwindow/**",
"internal/runtime/**",
"internal/config/**",
"cmd/gc/template_resolve*.go",
@@ -87,6 +91,7 @@ var requiredFilterPaths = map[string][]string{
"Makefile",
"internal/worker/**",
"internal/sessionlog/**",
+ "internal/modelwindow/**",
"internal/runtime/**",
"internal/config/**",
"cmd/gc/**",
diff --git a/scripts/lib/inner-parallelism.sh b/scripts/lib/inner-parallelism.sh
new file mode 100755
index 0000000000..60d98d95be
--- /dev/null
+++ b/scripts/lib/inner-parallelism.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# inner-parallelism.sh — computes GOFLAGS=-p= for the go-test binaries
+# launched by each outer test-local-parallel job (ga-04m84s).
+#
+# The outer job count (test-local-job-count) sizes concurrent shard
+# processes; each shard's `go test` binary defaults its internal -p to
+# GOMAXPROCS, so when multiple shards run concurrently they each
+# independently try to claim the whole machine, oversubscribing it.
+# gc_inner_parallelism divides the outer budget across however many
+# shards are actually running concurrently so each one's -p is capped to
+# its fair share instead.
+#
+# Scope: -p only bounds cross-package build/test-binary concurrency, not
+# within-package t.Parallel() fan-out (that's the separate -parallel flag,
+# also defaulting to GOMAXPROCS, which this fix does not set). Shards that
+# invoke go test against a single package -- most of cmd/gc's job list --
+# get -p bounded only for their dependency-build phase, not their
+# t.Parallel() run phase; the multi-package jobs get the full benefit.
+#
+# Source this file in other scripts:
+# source "$repo_root/scripts/lib/inner-parallelism.sh"
+
+# gc_inner_parallelism LOCAL_JOBS JOB_COUNT prints the -p value each
+# concurrent job should pass to `go test`. GC_TEST_INNER_P overrides the
+# computation outright (must be a positive integer) for deterministic tests.
+gc_inner_parallelism() {
+ local local_jobs="$1" job_count="$2"
+
+ if [[ -n "${GC_TEST_INNER_P:-}" ]]; then
+ [[ "$GC_TEST_INNER_P" =~ ^[0-9]+$ && "$GC_TEST_INNER_P" -gt 0 ]] ||
+ { echo "GC_TEST_INNER_P must be a positive integer" >&2; return 1; }
+ printf '%s\n' "$GC_TEST_INNER_P"
+ return
+ fi
+
+ local effective_outer="$job_count"
+ if (( local_jobs < effective_outer )); then
+ effective_outer="$local_jobs"
+ fi
+ local inner_p=$(( local_jobs / effective_outer ))
+ if (( inner_p < 1 )); then
+ inner_p=1
+ fi
+ printf '%s\n' "$inner_p"
+}
diff --git a/scripts/precommit_contract_test.go b/scripts/precommit_contract_test.go
index 125c6452d4..d80a601684 100644
--- a/scripts/precommit_contract_test.go
+++ b/scripts/precommit_contract_test.go
@@ -70,7 +70,8 @@ func TestTestFastParallelUsesSanitizedEnvironmentAndMachineAwareConcurrency(t *t
strings.HasPrefix(entry, "PUSH_GATE_MAX_CONCURRENT=") ||
strings.HasPrefix(entry, "PUSH_GATE_MAX_WAIT_SECONDS=") ||
strings.HasPrefix(entry, "PUSH_GATE_POLL_SECONDS=") ||
- strings.HasPrefix(entry, "PUSH_GATE_UNRELATED_SENTINEL=") {
+ strings.HasPrefix(entry, "PUSH_GATE_UNRELATED_SENTINEL=") ||
+ strings.HasPrefix(entry, "GC_TEST_LOCAL_LOADAVG=") {
continue
}
baseEnv = append(baseEnv, entry)
@@ -103,8 +104,12 @@ func TestTestFastParallelUsesSanitizedEnvironmentAndMachineAwareConcurrency(t *t
args = append(args, "test-fast-parallel")
cmd := exec.Command("make", args...)
cmd.Dir = repoRoot
+ // This table exercises the cpu/memory/cgroup axes only; pin loadavg=0
+ // so a live host's real /proc/loadavg can't shrink the expected job
+ // count out from under an unrelated case (ga-04m84s).
cmd.Env = append(append([]string(nil), baseEnv...),
"GC_TEST_LOCAL_CPUS="+tt.cpus,
+ "GC_TEST_LOCAL_LOADAVG=0",
"GC_PUSH_GATE_NO_CAP=1",
"PUSH_GATE_MAX_CONCURRENT=7",
"PUSH_GATE_MAX_WAIT_SECONDS=13",
@@ -337,6 +342,224 @@ exit 0
}
}
+func TestPreCommitFailsClosedWhenSpecStagedButNpmAbsent(t *testing.T) {
+ repoRoot := repoRoot(t)
+ hookPath := filepath.Join(repoRoot, ".githooks", "pre-commit")
+
+ tmpRepo := t.TempDir()
+ runGit := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = tmpRepo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.invalid",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.invalid",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ specPath := filepath.Join(tmpRepo, "internal", "api", "openapi.json")
+
+ runGit("init")
+ writeTestFile(t, specPath, "{}\n")
+ runGit("add", "-A")
+ runGit("commit", "-m", "init")
+
+ // Stage ONLY a change to openapi.json -- same repro shape as
+ // TestPreCommitReachesDashboardBlockWhenOnlySpecFileStaged, but this
+ // time npm itself is unreachable on PATH.
+ writeTestFile(t, specPath, `{"changed":true}`+"\n")
+ runGit("add", "internal/api/openapi.json")
+
+ cmd := exec.Command("bash", hookPath)
+ cmd.Dir = tmpRepo
+ cmd.Env = []string{
+ "PATH=" + restrictedPathWithoutNpm(t, nil),
+ "HOME=" + t.TempDir(),
+ }
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("pre-commit hook must fail when internal/api/openapi.json is staged and npm is not on PATH "+
+ "-- the generated TS client can't be regenerated, so the commit would silently ship a stale "+
+ "client with no enforcement until CI runs. Hook exited 0, output:\n%s", out)
+ }
+ if !strings.Contains(string(out), "npm ci") || !strings.Contains(string(out), "generate:client") {
+ t.Fatalf("pre-commit hook's npm-absent+spec-staged failure must name the exact recovery command "+
+ "(cd internal/api/dashboardspa/web && npm ci && npm run generate:client), got:\n%s", out)
+ }
+}
+
+func TestPreCommitFailsClosedWhenGoBlockStagesSpecAsSideEffectAndNpmAbsent(t *testing.T) {
+ repoRoot := repoRoot(t)
+ hookPath := filepath.Join(repoRoot, ".githooks", "pre-commit")
+
+ tmpRepo := t.TempDir()
+ runGit := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = tmpRepo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.invalid",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.invalid",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ goFilePath := filepath.Join(tmpRepo, "main.go")
+ specPath := filepath.Join(tmpRepo, "internal", "api", "openapi.json")
+ formatStagedGoPath := filepath.Join(tmpRepo, "scripts", "precommit-format-staged-go")
+ // Every path the Go block unconditionally `git add`s after each
+ // generation step must already exist on disk, or that `git add` fails
+ // closed under `set -euo pipefail` before the hook ever reaches the
+ // npm-absent branch this test targets.
+ generatedPaths := []string{
+ specPath,
+ filepath.Join(tmpRepo, "docs", "reference", "schema", "openapi.json"),
+ filepath.Join(tmpRepo, "docs", "reference", "schema", "openapi.txt"),
+ filepath.Join(tmpRepo, "internal", "api", "genclient", "client_gen.go"),
+ filepath.Join(tmpRepo, "docs", "reference", "schema", "city-schema.json"),
+ filepath.Join(tmpRepo, "docs", "reference", "schema", "city-schema.txt"),
+ filepath.Join(tmpRepo, "docs", "reference", "config.md"),
+ filepath.Join(tmpRepo, "docs", "reference", "cli.md"),
+ }
+
+ runGit("init")
+ writeTestFile(t, goFilePath, "package main\n\nfunc main() {}\n")
+ for _, p := range generatedPaths {
+ writeTestFile(t, p, "{}\n")
+ }
+ if err := os.MkdirAll(filepath.Dir(formatStagedGoPath), 0o755); err != nil {
+ t.Fatalf("create parent for %s: %v", formatStagedGoPath, err)
+ }
+ writeExecutable(t, formatStagedGoPath, "#!/usr/bin/env bash\nexit 0\n")
+ runGit("add", "-A")
+ runGit("commit", "-m", "init")
+
+ // Stage ONLY a .go file -- internal/api/openapi.json is untouched by the
+ // user's own `git add`. The hook's own Go block (staged_go_files branch)
+ // regenerates and stages openapi.json as a SIDE EFFECT via
+ // `go run ./cmd/genspec`, which is exactly the #4627/#4607 staleness
+ // trap the npm-present branch re-reads for (fresh spec_changed) but
+ // which the npm-absent fail-closed branch used to miss (ga-jg89a5): it
+ // checked a snapshot taken before the hook ran at all, so it never saw
+ // the spec this commit was actually about to ship.
+ writeTestFile(t, goFilePath, "package main\n\nfunc main() { println(1) }\n")
+ runGit("add", "main.go")
+
+ goStub := `#!/usr/bin/env bash
+set -euo pipefail
+if [ "$1" = "run" ] && [ "$2" = "./cmd/genspec" ]; then
+ printf '{"changed":true}\n' > internal/api/openapi.json
+fi
+exit 0
+`
+
+ cmd := exec.Command("bash", hookPath)
+ cmd.Dir = tmpRepo
+ cmd.Env = []string{
+ "PATH=" + restrictedPathWithoutNpm(t, map[string]string{
+ "make": "#!/usr/bin/env bash\nexit 0\n",
+ // Stands in for format/lint/genspec/genclient/genschema/vet.
+ // Only `run ./cmd/genspec` has an observable side effect
+ // (rewriting internal/api/openapi.json, which the hook's own
+ // `git add` then stages), matching what the real cmd/genspec
+ // does against a live Huma API -- the rest of the Go block is
+ // exercised for control-flow only.
+ "go": goStub,
+ }),
+ "HOME=" + t.TempDir(),
+ }
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("pre-commit hook must fail when its own Go block stages internal/api/openapi.json as a side "+
+ "effect (go run ./cmd/genspec, triggered by staging a .go file) and npm is not on PATH -- the "+
+ "generated TS client can't be regenerated, so the commit would silently ship a stale client with "+
+ "no enforcement until CI runs. Hook exited 0, output:\n%s", out)
+ }
+ if !strings.Contains(string(out), "npm ci") || !strings.Contains(string(out), "generate:client") {
+ t.Fatalf("pre-commit hook's npm-absent+spec-staged-as-side-effect failure must name the exact "+
+ "recovery command (cd internal/api/dashboardspa/web && npm ci && npm run generate:client), got:\n%s", out)
+ }
+}
+
+func TestPreCommitWarnsOnlyWhenNpmAbsentAndSpecNotStaged(t *testing.T) {
+ repoRoot := repoRoot(t)
+ hookPath := filepath.Join(repoRoot, ".githooks", "pre-commit")
+
+ tmpRepo := t.TempDir()
+ runGit := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = tmpRepo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.invalid",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.invalid",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ docPath := filepath.Join(tmpRepo, "README.md")
+
+ runGit("init")
+ writeTestFile(t, docPath, "hello\n")
+ runGit("add", "-A")
+ runGit("commit", "-m", "init")
+
+ // Stage a docs-only change -- internal/api/openapi.json is untouched,
+ // so npm's absence must stay a warning, not a hard failure. staged_docs
+ // being non-empty also exercises `make check-docs`, so stub `make` as a
+ // no-op; the fixture repo has none of the real doc-lint machinery.
+ writeTestFile(t, docPath, "hello again\n")
+ runGit("add", "README.md")
+
+ cmd := exec.Command("bash", hookPath)
+ cmd.Dir = tmpRepo
+ cmd.Env = []string{
+ "PATH=" + restrictedPathWithoutNpm(t, map[string]string{
+ "make": "#!/usr/bin/env bash\nexit 0\n",
+ }),
+ "HOME=" + t.TempDir(),
+ }
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("pre-commit hook must still succeed (warn-only) when npm is absent and "+
+ "internal/api/openapi.json is NOT staged -- contributors without Node tooling must not be "+
+ "blocked on unrelated commits, got exit error: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "npm not on PATH") {
+ t.Fatalf("pre-commit hook should still warn when npm is absent, got:\n%s", out)
+ }
+}
+
+// restrictedPathWithoutNpm builds a PATH containing only symlinks to the
+// real bash and git (plus any provided stub scripts), guaranteeing npm is
+// unreachable regardless of what's installed on the test host -- falling
+// back to the ambient PATH would make these tests flaky on any machine
+// that actually has npm installed.
+func restrictedPathWithoutNpm(t *testing.T, stubs map[string]string) string {
+ t.Helper()
+ binDir := t.TempDir()
+ for _, name := range []string{"bash", "git", "xargs"} {
+ realPath, err := exec.LookPath(name)
+ if err != nil {
+ t.Fatalf("resolve real %s on test host PATH: %v", name, err)
+ }
+ if err := os.Symlink(realPath, filepath.Join(binDir, name)); err != nil {
+ t.Fatalf("symlink %s: %v", name, err)
+ }
+ }
+ for name, script := range stubs {
+ writeExecutable(t, filepath.Join(binDir, name), script)
+ }
+ return binDir
+}
+
func TestNativeDoltliteBeadsTargetRunsTaggedSuite(t *testing.T) {
repoRoot := repoRoot(t)
makefile, err := os.ReadFile(filepath.Join(repoRoot, "Makefile"))
diff --git a/scripts/push-gate-lock-lib.sh b/scripts/push-gate-lock-lib.sh
index 34f06d59ff..b48328fce0 100755
--- a/scripts/push-gate-lock-lib.sh
+++ b/scripts/push-gate-lock-lib.sh
@@ -68,10 +68,15 @@
# - Malformed tunables fall back to their documented defaults with a
# diagnostic naming the offending variable; they never reach arithmetic
# or `sleep` unvalidated.
-# - A timed-out acquire returns 1 (shell-false). This library never calls
-# `exit` itself — mapping a timeout to process exit code 75 is the
-# caller's job (scripts/test-local-parallel), keeping this file a pure,
-# testable function library.
+# - A timed-out acquire returns 1 (shell-false), and ONLY a timed-out
+# acquire returns 1 — every degrade case (missing flock(1), a slot dir
+# that cannot be created) prints its own diagnostic and returns 0 with
+# an empty fd instead, so callers can trust that a 1 always means a
+# real wait-bound expiry, never an environment defect misreported as
+# fleet contention. This library never calls `exit` itself — mapping a
+# timeout to process exit code 75 is the caller's job
+# (scripts/test-local-parallel), keeping this file a pure, testable
+# function library.
#
# FUNCTIONS
# push_gate_city_root
@@ -100,12 +105,15 @@
# PUSH_GATE_MAX_WAIT_SECONDS (default 600), PUSH_GATE_POLL_SECONDS
# (default 15); each is validated and falls back to its default on a
# malformed value. holder_label defaults to
-# ${GC_SESSION_NAME:-${GC_AGENT:-${GC_TEMPLATE:-unknown}}}. Sweeps
-# slots 0..N-1 non-blocking; acquires the first free one immediately
-# (fd assigned to the caller's , return 0). If all slots
-# are busy: prints an immediate unbuffered diagnostic naming current
-# holders (FR5), then re-sweeps every POLL_SECONDS until a slot frees
-# or MAX_WAIT_SECONDS elapses. Returns 0 (acquired) or 1 (timed out —
+# ${GC_SESSION_NAME:-${GC_AGENT:-${GC_TEMPLATE:-unknown}}}. If the slot
+# dir cannot be created (e.g. an unwritable parent), degrades the same
+# way as a missing flock(1): diagnostic to stderr, empty fd, return 0
+# — never conflated with a timeout. Otherwise sweeps slots 0..N-1
+# non-blocking; acquires the first free one immediately (fd assigned
+# to the caller's , return 0). If all slots are busy:
+# prints an immediate unbuffered diagnostic naming current holders
+# (FR5), then re-sweeps every POLL_SECONDS until a slot frees or
+# MAX_WAIT_SECONDS elapses. Returns 0 (acquired) or 1 (timed out —
# caller should `exit 75`).
# push_gate_describe_slots
# Print one "slot-: " line per currently-occupied
@@ -276,7 +284,16 @@ push_gate_acquire_slot() {
local _pgl_host
_pgl_host="$(hostname 2>/dev/null || echo unknown)"
- mkdir -p "$_pgl_slot_dir" 2>/dev/null || return 1
+ # An unwritable slot dir (e.g. a parent path component that is a file,
+ # as .git is in a linked worktree prior to push_gate_slots_dir's
+ # common-dir fix) is a degrade case, not a wait-bound timeout — same
+ # `return 1` used to mean both, which sent operators chasing fleet
+ # contention that did not exist. Degrade best-effort instead.
+ if ! mkdir -p "$_pgl_slot_dir" 2>/dev/null; then
+ echo "push-gate: cannot create slot dir $_pgl_slot_dir — running without a cross-invocation cap" >&2
+ eval "$_pgl_fd_var="
+ return 0
+ fi
local _pgl_i _pgl_slot _pgl_fd _pgl_announced=0 _pgl_start=0
diff --git a/scripts/push-ownership-guard.sh b/scripts/push-ownership-guard.sh
index c76199643f..bb596f0e27 100755
--- a/scripts/push-ownership-guard.sh
+++ b/scripts/push-ownership-guard.sh
@@ -36,6 +36,13 @@
# response doesn't parse) blocks the push. The only sanctioned bypass is
# `git push --no-verify` for Layer A; Layer B has no bypass by design — an
# automated force-push is exactly the case this guard exists to stop.
+# EXCEPTION (deploy/*-gate branches): these deliberately ignore the
+# branch-embedded id and resolve solely via the assignee fallback (see
+# _pog_resolve_bead_id). A *failed* assignee read is still ambiguity and
+# still blocks; but a read that succeeds and finds no in-progress
+# assignment leaves nothing to check, and the push is allowed — the same
+# "no session, nothing to check" semantics every unmatched branch already
+# has.
#
# This file ONLY defines functions and one default-value assignment;
# sourcing it must not produce output or otherwise mutate state.
@@ -45,8 +52,25 @@
# attempt_bounded_self_rebase directly against synthetic repos with no real
# bead behind them (e.g. scripts/test-rebase-resolve.sh) and must stay
# hermetic — it is not meant to be set on a real push path.
+#
+# bd/Dolt reads below are wrapped by _pog_read_with_retry: a transient
+# failure (lock contention, a slow response) is retried up to
+# POG_READ_ATTEMPTS times, each attempt bounded by POG_TIMEOUT_SECONDS, with
+# a short sleep between attempts. Only once every attempt fails does the
+# guard block — this does not weaken fail-closed semantics (a persistently
+# unreachable bd still blocks) and does not mask a genuine ownership change
+# (a real answer, allow or block, is accepted on its first attempt; only a
+# failed/empty read is retried). Override POG_READ_ATTEMPTS for test
+# harnesses that want to exercise a specific attempt count without eating
+# the real sleep/timeout cost of the production default.
POG_TIMEOUT_SECONDS="${POG_TIMEOUT_SECONDS:-5}"
+POG_READ_ATTEMPTS="${POG_READ_ATTEMPTS:-3}"
+
+# Sentinel emitted by _pog_resolve_bead_id when it cannot resolve an id
+# *and* the failure is ambiguous (a failed bd read) rather than a clean
+# "no such assignment". Not a valid bead id by construction.
+POG_AMBIGUOUS_SENTINEL="__pog_unresolved_ambiguous__"
# _pog_timeout