Repository navigation
design phase 8 c4
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.
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).
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).
-
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.
- 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.
- 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).