design phase 8 c4 - sinrimin/harness-nexus GitHub Wiki
Design: Phase 8 C4 โ remote deploy (jobs over the 3.3 pipeline + agent instances)
Status: implemented (branch
phase-8-c4). Parent design:design-phase-8-client.mdยง "Jobs & remote deploy (C4)". PRD:prd-phase-8-client.md("ๅๅปบAgentๅๆฏๅฏไปฅ้ๆฉไธ็งๆฏๆ็Agent+profile ไธ้ฎ้จ็ฝฒ"). C3:design-phase-8-c3.md(shipped โ inventory/import). Verification: server 42 (fake-daemon lifecycle incl. offline queue โ reconnect replay, disconnect recovery to the JOB_ABANDONED cap, ack-timeout sweep, gates), smoke[8 C4]with the REAL daemon dist โ offline queue โ connect โ deploy-bundle fetch via machine PAT โ 3.3 pipeline writes the fixture~/.hermes+ ledger โ AgentInstance upsert on redeploy โ 240/240 overall; SQLite0009job/instance upsert + cascade verified.
Scope
C4 turns "install a profile on a machine" into a platform-issued, replayable
job โ the create-agent half of the vision (hnx install stays as the
manual local path; same code, different trigger):
- Job model (
core/domain/job.ts, migration0009):Job {id, machineId, ownerId, type: 'deploy', status: queued|dispatched|running|succeeded|failed| cancelled, payload, result, error, attempts, createdAt, updatedAt}+JobRepository(findById / listByMachine / listRecoverable / save / deleteByMachine).jobTypeSchemakeeps'scan' | 'import'reserved, but C3's interactive request/response flows STAY as they are โ jobs exist for fire-and-forget work whose value is offline queue + replay (deploy); an interactive scan/import gains nothing from queue semantics (documented absorption decision, replaces the parent doc's "absorbs these flows"). - AgentInstance model (same migration):
{id, machineId, ownerId, target, profileId, profileVersion, name, directory, jobId, createdAt, updatedAt}โ what C5 chats with and C6 orchestrates. Registered on deploy success; upserted by (machineId, profileId) so re-deploying = upgrading one instance. Machine delete cascades both tables. JobService(server/src/jobs/service.ts, decoratedapp.jobs):createJobโqueued, immediate dispatch attempt when online.- Dispatch = emit
job:dispatch{job: JobView}tomachine:<id>on/ctl(ack = accepted, not completed) โ statusdispatched. onProgressโrunning(phase/message are transient pushes, not persisted โ the jobs table shows status, not a progress log).onResultโsucceeded(deploy + ok โ register/upsert the AgentInstance fromresult.data) |failed.cancelโ queued only (dispatched/runningare already with the daemon; killing a mid-apply install is not a v1 semantic โ409 JOB_NOT_CANCELLABLE).- Recovery: machine disconnect โ
dispatched/runningjobs revert toqueuedwithattempts+1(plans are idempotent overwrites, so replay is safe) โattempts โฅ jobMaxAttempts(3) โfailed JOB_ABANDONED. A periodic sweep (15s) revertsdispatchedrows older thanjobAckTimeoutMs(60s) the same way;runningrows are never swept by timer (the daemon may legitimately still be working โ disconnect is their only recovery path; documented). - Machine comes online โ
dispatchPendingdrains the queue. - Every transition pushes
job:update{job: JobView}to the owner (+admins) on/app.
- REST (
modules/jobs.ts, machine owner-or-admin + 404-hiding):POST /api/machines/:id/jobs{profileId, directory?}โ profile must be visible and its target deployable (DEPLOYABLE_TARGETS = hermes | codex; claude-code is served by the 3.5 marketplace emitter instead โ409 TARGET_NOT_DEPLOYABLE).GET /api/machines/:id/jobsโ newest first.POST /api/jobs/:id/cancelโ owner-or-admin.GET /api/machines/:id/agentsโ AgentInstance rows for the machine.
- Deploy bundle (
modules/client-config.tsโ machine PAT exception #2, identical auth/visibility treatment to/api/client/mcp-config):GET /api/client/deploy-bundle?profile=<id>โ theResolvedProfileJSON ({profile, artifacts}) the 3.3 resolver would otherwise fetch N+1. The job payload stays tiny; skill bundles don't fightmaxHttpBufferSize. - Daemon executor (
cli/src/daemon/jobs.ts, capability'deploy'): onjob:dispatchโ ack{accepted:true}โ fetch the bundle with the machine PAT โ setHN_SERVERโplanInstall(progressplan) โapplyInstall+ ledger (progressapply) โjob:resultok with{name, directory, target, profileId, profileVersion}or the error.payload.directorymaps to the planner'soutDiroverride. - Web (MachineDetail page grows a Deployments section): deploy form
(profile picker filtered to deployable targets), a jobs table with live
status via
job:update, and the machine's agent instances. No new top-level route. - SDK:
createMachineJob/listMachineJobs/cancelJob/listMachineAgents+JobView/AgentInstanceView. - Config:
JOB_ACK_TIMEOUT_MS(60s),JOB_SWEEP_INTERVAL_MS(15s),JOB_MAX_ATTEMPTS(3).
Job lifecycle (normative)
create โโโถ queued โโdispatchโโโถ dispatched โโprogressโโโถ running โโresultโโโถ succeeded | failed
โฒ โ โ
โโโ cancel โโโ (queued only) โ
โฒ โ
โโโโโโ disconnect / ack-timeout sweep โโโโโโโโโโโโโโโ
(attempts+1; attempts โฅ 3 โ failed JOB_ABANDONED)
- Terminal states (
succeeded | failed | cancelled) never transition again; latejob:progress/job:resultfor a terminal job are ignored (stale daemon replay after recovery must not clobber a redelivered job's new attempt). - A
dispatchedjob that receives progress from a PREVIOUS attempt (stale socket delivery) is tolerated: progress for adispatchedjob is accepted (it means the daemon started working โ transition to running).
Tasks (dependency order)
- shared โ
deployJobPayloadSchema,jobUpdateEventSchema,jobViewSchema.attempts(additive optional),agentInstanceViewSchema. Unit tests. - core โ
domain/job.ts(Job + AgentInstance), ports + UnitOfWork arms. - server storage โ migration
0009(jobs + agent_instances, FKs cascade), repos in both drivers, machine-delete cascade. - server jobs โ
JobService+ realtime wiring (job:progress/job:resulthandlers, online โdispatchPending, disconnect โ recovery)- sweep lifecycle (start/stop with the app).
- server routes โ
modules/jobs.ts+ deploy-bundle in client-config + config keys. - cli โ daemon job executor + capability bump.
- sdk + web โ methods/views + MachineDetail Deployments section.
- verify โ server integration with a fake daemon (offline create โ
queued โ connect โ dispatched โ progress โ succeeded + AgentInstance
upsert; failure path; queued cancel; 409 gates; disconnect recovery incl.
attempts โ JOB_ABANDONED with injected short timeouts); smoke
[8 C4]with the REAL daemon dist: create profile+resources via API โ create deploy job on a fixture-HOME machine โ job succeeds โ~/.hermesfiles + ledger + AgentInstance registered โ redeploy same profile โ same instance row upgraded, job #2 succeeded.
Security notes
- The deploy bundle endpoint is machine-PAT reachable but bound to the
machine owner's visibility โ the same contract as
/api/client/mcp-config(documented as REST exception #2). No other REST surface opens. - Job payloads never carry secrets; the daemon fetches the bundle itself
over TLS with its own token.
directoryis daemon-side validated by the planner's root resolution. job:result.datais daemon-reported and persisted verbatim โ it contains paths/names only, and the server treats it as display data, not as an execution surface.- Cancel is queued-only by design: interrupting a mid-apply install could leave a half-written agent home with no ledger; replay (idempotent overwrite) is the recovery story instead.
Out of scope (explicit)
- Uninstall jobs (the local
hnx uninstallremains the reversal path). - Per-step persisted progress log / live log streaming (
job:progresssurfaces phase only in v1). - Running-job timeout sweep (disconnect is the recovery path; documented).
- Deploy to
claude-code(marketplace emitter is that target's path) andzcode(no adapter). - Job scheduling/cron, concurrent per-machine job limits (v1 dispatches all queued jobs; the daemon executes serially per event loop anyway).